From c40d71e1edd3ece4085a948025d9d78efa3458ee Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Fri, 5 Aug 2022 12:01:46 +0100 Subject: [PATCH] feat (#804): simple proposal form (#942) * Feat/804: Routing and basic page setup * Feat/804: Type definition for proposals - awaiting asset docs/changes * Feat/804: New proposals lib * Feat/804: Propose form and page * Feat/804: Removing dud copied unit tests for now * Feat/804: Added types for new asset proposal * feat: handle new error types returned from wallet for proposals * chore: rename lib to governance * feat: move usage of hook into form component * feat: some adjustments and test coverage * chore: tidy up, remove tailwind config Co-authored-by: Matthew Russell --- .../deal-ticket/deal-ticket-steps.tsx | 12 +- apps/token/src/i18n/translations/dev.json | 7 +- .../src/routes/governance/propose/index.tsx | 1 + .../src/routes/governance/propose/propose.tsx | 21 +++ apps/token/src/routes/router-config.tsx | 2 + .../src/components/deal-ticket-manager.tsx | 18 +- libs/governance/.babelrc | 12 ++ libs/governance/.eslintrc.json | 18 ++ libs/governance/README.md | 7 + libs/governance/jest.config.js | 10 + libs/governance/package.json | 4 + libs/governance/project.json | 43 +++++ libs/governance/src/index.ts | 2 + libs/governance/src/lib/index.ts | 2 + .../governance/src/lib/proposal-form.spec.tsx | 175 ++++++++++++++++++ libs/governance/src/lib/proposal-form.tsx | 81 ++++++++ .../__generated__/ProposalEvent.ts | 63 +++++++ .../src/lib/proposals-hooks/index.ts | 3 + .../lib/proposals-hooks/use-proposal-event.ts | 75 ++++++++ .../proposals-hooks/use-proposal-submit.ts | 58 ++++++ libs/governance/src/setup-tests.ts | 1 + libs/governance/src/utils/index.ts | 1 + .../src/utils/proposal-dialog-helpers.tsx | 74 ++++++++ libs/governance/tsconfig.json | 25 +++ libs/governance/tsconfig.lib.json | 26 +++ libs/governance/tsconfig.spec.json | 19 ++ libs/wallet/src/connectors/rest-connector.ts | 12 +- libs/wallet/src/wallet-types.ts | 169 ++++++++++++++++- tsconfig.base.json | 1 + workspace.json | 1 + 30 files changed, 925 insertions(+), 18 deletions(-) create mode 100644 apps/token/src/routes/governance/propose/index.tsx create mode 100644 apps/token/src/routes/governance/propose/propose.tsx create mode 100644 libs/governance/.babelrc create mode 100644 libs/governance/.eslintrc.json create mode 100644 libs/governance/README.md create mode 100644 libs/governance/jest.config.js create mode 100644 libs/governance/package.json create mode 100644 libs/governance/project.json create mode 100644 libs/governance/src/index.ts create mode 100644 libs/governance/src/lib/index.ts create mode 100644 libs/governance/src/lib/proposal-form.spec.tsx create mode 100644 libs/governance/src/lib/proposal-form.tsx create mode 100644 libs/governance/src/lib/proposals-hooks/__generated__/ProposalEvent.ts create mode 100644 libs/governance/src/lib/proposals-hooks/index.ts create mode 100644 libs/governance/src/lib/proposals-hooks/use-proposal-event.ts create mode 100644 libs/governance/src/lib/proposals-hooks/use-proposal-submit.ts create mode 100644 libs/governance/src/setup-tests.ts create mode 100644 libs/governance/src/utils/index.ts create mode 100644 libs/governance/src/utils/proposal-dialog-helpers.tsx create mode 100644 libs/governance/tsconfig.json create mode 100644 libs/governance/tsconfig.lib.json create mode 100644 libs/governance/tsconfig.spec.json diff --git a/apps/simple-trading-app/src/app/components/deal-ticket/deal-ticket-steps.tsx b/apps/simple-trading-app/src/app/components/deal-ticket/deal-ticket-steps.tsx index 4be517c03..50bf841ab 100644 --- a/apps/simple-trading-app/src/app/components/deal-ticket/deal-ticket-steps.tsx +++ b/apps/simple-trading-app/src/app/components/deal-ticket/deal-ticket-steps.tsx @@ -6,9 +6,9 @@ import type { DealTicketQuery_market } from '@vegaprotocol/deal-ticket'; import { InputError } from '@vegaprotocol/ui-toolkit'; import { DealTicketAmount, - getDialogTitle, - getDialogIntent, - getDialogIcon, + getOrderDialogTitle, + getOrderDialogIntent, + getOrderDialogIcon, MarketSelector, } from '@vegaprotocol/deal-ticket'; import type { Order } from '@vegaprotocol/orders'; @@ -143,9 +143,9 @@ export const DealTicketSteps = ({ partyData={partyData} /> diff --git a/apps/token/src/i18n/translations/dev.json b/apps/token/src/i18n/translations/dev.json index 11eef38e0..a90608a8e 100644 --- a/apps/token/src/i18n/translations/dev.json +++ b/apps/token/src/i18n/translations/dev.json @@ -157,6 +157,9 @@ "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 Tranches. Each tranche has its own schedule for how the tokens are unlocked.": "Tokens are held in different Tranches. Each tranche has its own schedule for how the tokens are unlocked.", "proposals": "Proposals", + "proposal": "Proposal", + "submitting": "Submitting", + "submit": "Submit", "proposedEnactment": "Proposed enactment", "Enacted": "Enacted", "enactedOn": "Enacted on", @@ -620,5 +623,7 @@ "InvalidAssetDetails": "Invalid asset details", "FilterProposals": "Filter proposals", "FilterProposalsDescription": "Filter by proposal ID or proposer ID", - "Freeform proposal": "Freeform proposal" + "Freeform proposal": "Freeform proposal", + "NewProposal": "New proposal", + "MinProposalRequirements": "You must have at least 1 VEGA to make a proposal" } diff --git a/apps/token/src/routes/governance/propose/index.tsx b/apps/token/src/routes/governance/propose/index.tsx new file mode 100644 index 000000000..9ee87e429 --- /dev/null +++ b/apps/token/src/routes/governance/propose/index.tsx @@ -0,0 +1 @@ +export { Propose } from './propose'; diff --git a/apps/token/src/routes/governance/propose/propose.tsx b/apps/token/src/routes/governance/propose/propose.tsx new file mode 100644 index 000000000..f511d42c8 --- /dev/null +++ b/apps/token/src/routes/governance/propose/propose.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next'; +import { ProposalForm } from '@vegaprotocol/governance'; +import { Heading } from '../../../components/heading'; +import { VegaWalletContainer } from '../../../components/vega-wallet-container'; + +export const Propose = () => { + const { t } = useTranslation(); + return ( + <> + + + {() => ( + <> +

{t('MinProposalRequirements')}

+ + + )} +
+ + ); +}; diff --git a/apps/token/src/routes/router-config.tsx b/apps/token/src/routes/router-config.tsx index 04d9ba59c..a33818425 100644 --- a/apps/token/src/routes/router-config.tsx +++ b/apps/token/src/routes/router-config.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ProposalContainer } from './governance/proposal'; import { ProposalsContainer } from './governance/proposals'; +import { Propose } from './governance/propose'; import Home from './home'; import NotFound from './not-found'; @@ -165,6 +166,7 @@ const routerConfig = [ component: LazyGovernance, children: [ { path: ':proposalId', element: }, + { path: 'propose', element: }, { index: true, element: }, ], }, diff --git a/libs/deal-ticket/src/components/deal-ticket-manager.tsx b/libs/deal-ticket/src/components/deal-ticket-manager.tsx index 01d0d2e86..a8a1922b0 100644 --- a/libs/deal-ticket/src/components/deal-ticket-manager.tsx +++ b/libs/deal-ticket/src/components/deal-ticket-manager.tsx @@ -34,9 +34,9 @@ export const DealTicketManager = ({ /> )} @@ -44,7 +44,9 @@ export const DealTicketManager = ({ ); }; -export const getDialogTitle = (status?: OrderStatus): string | undefined => { +export const getOrderDialogTitle = ( + status?: OrderStatus +): string | undefined => { if (!status) { return; } @@ -63,7 +65,9 @@ export const getDialogTitle = (status?: OrderStatus): string | undefined => { } }; -export const getDialogIntent = (status?: OrderStatus): Intent | undefined => { +export const getOrderDialogIntent = ( + status?: OrderStatus +): Intent | undefined => { if (!status) { return; } @@ -81,7 +85,9 @@ export const getDialogIntent = (status?: OrderStatus): Intent | undefined => { } }; -export const getDialogIcon = (status?: OrderStatus): ReactNode | undefined => { +export const getOrderDialogIcon = ( + status?: OrderStatus +): ReactNode | undefined => { if (!status) { return; } diff --git a/libs/governance/.babelrc b/libs/governance/.babelrc new file mode 100644 index 000000000..ccae900be --- /dev/null +++ b/libs/governance/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nrwl/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/governance/.eslintrc.json b/libs/governance/.eslintrc.json new file mode 100644 index 000000000..db820c5d0 --- /dev/null +++ b/libs/governance/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "__generated__"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/governance/README.md b/libs/governance/README.md new file mode 100644 index 000000000..0bdcff800 --- /dev/null +++ b/libs/governance/README.md @@ -0,0 +1,7 @@ +# governance + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test governance` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/governance/jest.config.js b/libs/governance/jest.config.js new file mode 100644 index 000000000..f8776b612 --- /dev/null +++ b/libs/governance/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: 'governance', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/governance', + setupFilesAfterEnv: ['./src/setup-tests.ts'], +}; diff --git a/libs/governance/package.json b/libs/governance/package.json new file mode 100644 index 000000000..24c7e4376 --- /dev/null +++ b/libs/governance/package.json @@ -0,0 +1,4 @@ +{ + "name": "@vegaprotocol/governance", + "version": "0.0.1" +} diff --git a/libs/governance/project.json b/libs/governance/project.json new file mode 100644 index 000000000..d4a791887 --- /dev/null +++ b/libs/governance/project.json @@ -0,0 +1,43 @@ +{ + "root": "libs/governance", + "sourceRoot": "libs/governance/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nrwl/web:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/governance", + "tsConfig": "libs/governance/tsconfig.lib.json", + "project": "libs/governance/package.json", + "entryFile": "libs/governance/src/index.ts", + "external": ["react/jsx-runtime"], + "rollupConfig": "@nrwl/react/plugins/bundle-rollup", + "compiler": "babel", + "assets": [ + { + "glob": "libs/governance/README.md", + "input": ".", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/governance/**/*.{ts,tsx,js,jsx}"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/governance"], + "options": { + "jestConfig": "libs/governance/jest.config.js", + "passWithNoTests": true + } + } + } +} diff --git a/libs/governance/src/index.ts b/libs/governance/src/index.ts new file mode 100644 index 000000000..e460a630c --- /dev/null +++ b/libs/governance/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib'; +export * from './utils'; diff --git a/libs/governance/src/lib/index.ts b/libs/governance/src/lib/index.ts new file mode 100644 index 000000000..e6f6ab975 --- /dev/null +++ b/libs/governance/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from './proposals-hooks'; +export * from './proposal-form'; diff --git a/libs/governance/src/lib/proposal-form.spec.tsx b/libs/governance/src/lib/proposal-form.spec.tsx new file mode 100644 index 000000000..e5791414b --- /dev/null +++ b/libs/governance/src/lib/proposal-form.spec.tsx @@ -0,0 +1,175 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { MockedResponse } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing'; +import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; +import { VegaWalletContext } from '@vegaprotocol/wallet'; +import { + BusEventType, + ProposalRejectionReason, + ProposalState, +} from '@vegaprotocol/types'; +import { ProposalForm } from './proposal-form'; +import { PROPOSAL_EVENT_SUB } from './proposals-hooks'; +import type { ProposalEvent } from './proposals-hooks/__generated__/ProposalEvent'; + +describe('ProposalForm', () => { + const pubkey = '0x123'; + const mockProposalEvent: MockedResponse = { + request: { + query: PROPOSAL_EVENT_SUB, + variables: { + partyId: pubkey, + }, + }, + result: { + data: { + busEvents: [ + { + __typename: 'BusEvent', + type: BusEventType.Proposal, + event: { + __typename: 'Proposal', + id: '2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50', + reference: 'proposal-reference', + state: ProposalState.Open, + rejectionReason: ProposalRejectionReason.CloseTimeTooLate, + errorDetails: 'error-details', + }, + }, + ], + }, + }, + delay: 300, + }; + const setup = (mockSendTx = jest.fn()) => { + return render( + + + + + + ); + }; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('handles validation', async () => { + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve()); + setup(mockSendTx); + + fireEvent.click(screen.getByTestId('proposal-submit')); + expect(mockSendTx).not.toHaveBeenCalled(); + + expect(await screen.findByTestId('input-error-text')).toHaveTextContent( + 'Required' + ); + + fireEvent.change(screen.getByTestId('proposal-data'), { + target: { value: 'invalid' }, + }); + + fireEvent.click(screen.getByTestId('proposal-submit')); + expect(mockSendTx).not.toHaveBeenCalled(); + + expect(await screen.findByTestId('input-error-text')).toHaveTextContent( + 'Must be valid JSON' + ); + }); + + it('sends the transaction', async () => { + const mockSendTx = jest.fn().mockReturnValue( + new Promise((resolve) => { + setTimeout( + () => + resolve({ + txHash: 'tx-hash', + tx: { + signature: { + value: + 'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909', + }, + }, + }), + 100 + ); + }) + ); + setup(mockSendTx); + + const inputJSON = '{}'; + fireEvent.change(screen.getByTestId('proposal-data'), { + target: { value: inputJSON }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('proposal-submit')); + }); + + expect(mockSendTx).toHaveBeenCalledWith({ + propagate: true, + pubKey: pubkey, + proposalSubmission: JSON.parse(inputJSON), + }); + + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Confirm transaction in wallet' + ); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Awaiting network confirmation' + ); + + await act(async () => { + jest.advanceTimersByTime(400); + }); + + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Proposal submitted' + ); + }); + + it('can be rejected by the user', async () => { + const mockSendTx = jest.fn().mockReturnValue( + new Promise((resolve) => { + setTimeout(() => resolve(null), 100); + }) + ); + setup(mockSendTx); + + const inputJSON = '{}'; + fireEvent.change(screen.getByTestId('proposal-data'), { + target: { value: inputJSON }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('proposal-submit')); + }); + + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Confirm transaction in wallet' + ); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/governance/src/lib/proposal-form.tsx b/libs/governance/src/lib/proposal-form.tsx new file mode 100644 index 000000000..132b2272b --- /dev/null +++ b/libs/governance/src/lib/proposal-form.tsx @@ -0,0 +1,81 @@ +import { + Button, + FormGroup, + InputError, + TextArea, +} from '@vegaprotocol/ui-toolkit'; +import { useForm } from 'react-hook-form'; +import { useProposalSubmit } from './proposals-hooks'; +import { + getProposalDialogIcon, + getProposalDialogIntent, + getProposalDialogTitle, +} from '../utils'; +import { t } from '@vegaprotocol/react-helpers'; + +export interface FormFields { + proposalData: string; +} + +export const ProposalForm = () => { + const { + register, + handleSubmit, + formState: { isSubmitting, errors }, + } = useForm(); + const { finalizedProposal, submit, TransactionDialog } = useProposalSubmit(); + + const hasError = Boolean(errors.proposalData?.message); + + const onSubmit = async (fields: FormFields) => { + await submit(JSON.parse(fields.proposalData)); + }; + + return ( +
+ +