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