Feat/800 better proposal ux round 1 (#1223)

* Feat/800: Configured routes for different proposal types

* Feat/800: Refactored propose.tsx to be a hub for the new proposal routes

* Feat/800: Link style tweak for proposals guide

* Feat/927: Hydrating the new proposal forms

* chore: fix typings for shared components

* Feat/927: Proposal forms built with reusable components

* Feat/800: Updated network params, added new method to get param keys as well as values, updated generated types

* Feat/800: Updated and built more reusable proposal form components

* Feat/800: Removed old catchall file of reusable proposal form components

* Feat/800: Added utils for vote deadline and enactment timestamps

* Feat/800: Readded necessary 'color-scheme-dark' class for dark-theme date and datetime inputs

* Feat/800: Tweak to icon positioning on the dialog component

* Feat/800: Regenerated types for network params

* Feat/800: Added iso8601-duration package for working with API vote deadline and enactment data

* Feat/800: Reworked the proposal forms

* Feat/800: Couple of translation additions

* Feat/800: Another translation addition

* Feat/800: Raw proposal test (tests as per old proposal form test)

* Feat/800: Some basic proposal form tests

* Feat/800: Fixing small types error in test

* Feat/800: Updating simple proposal form tests

* Feat/800: Set up env-specific proposal docs links

* Feat/800: Deadlines to the bottom of proposal forms

* Feat/800: Another type error from API changes fixed

* Feat/800: Added the spam protection min tokens network param to proposal forms requests, and the min requirements now displays the larger value of spam protection or min proposer balance

* Feat/800: Network param value change now a textarea

* Feat/800: Improved way to pass docs url

* Feat/800: Added useful explorer links to proposal forms

* Feat/800: Show current value of selected network param in proposal form

* Feat/800: Removed wallet-confirmation buffer, suspect it is not necessary

* Feat/800: Condense vote and enactment ui into single component for simpler state management, memoised some stuff

* Feat/800: Set a default select option for the market selector

* Feat/800: For network params that aren't JSON, display the network param current value in a readonly text input rather than the syntax highlighter

* Feat/800: Corrected network param form network params

* Feat/800: Timestamp functions for closing, enactment and validation, with tests

* Feat/800: More translations

* Feat/800: Added validation options to proposal-form-vote-and-enactment-deadline.tsx, generally improved the component and added tests

* Feat/800: Improved workings of proposal-form-min-requirements.tsx and wrote tests

* Feat/800: Tests for the other reusable form components

* Feat/800: Improved tests for the proposal forms

* Feat/800: Corrected mistake in get-enactment-timestamp.ts

* Feat/800: Fixed type issues that were preventing spotting an error

* Feat/800: Added some extra docs links

* Feat/800: Using renamed Dialog passed in from useProposalSubmit

* refactor: network params to return an object

* fix: update net param form, remove old net params obj

* fix: check for params before rendering

* Feat/800: Rename and simplify isJson based off PR comment

* Feat/800: Small tweaks to timestamp helpers based on PR comments

* Feat/800: Removed proposal-docs-link.tsx

* Feat/800: Used bignumber for min-requirements calculation

* Feat/800: Update tests to work with merged network params branch

* Feat/800: Removed unnecessary translations splitting

* Feat/800: Removed unwanted test

* Feat/800: Removed unwanted tests

* Feat/800: Consistent arrow functions

* Feat/800: Sorted links

* Feat/800: Removed unnecessary typecasting

* Feat/800: Refactored routing

* Feat/800: Refactored mocking of proposal forms to use MockedProvider

* Feat/800: Correct response from the raw proposal submission test

* Feat/800: Removed console.logs added for debugging

* Feat/800: Result of running 'nx format:write'

* Feat/800: Cleaning up lint warnings

* Update apps/token/.env.stagnet3

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Feat/800: Added extra test for docs link in the proposal-form-terms component

* Feat/800: Removed stray console.log

* Feat/800: Added basic render test for proposal-form-terms and cleaned up a bit

* Feat/800: Added tests for the correct form components for the different forms

* Feat/800: Split up the proposal-form-vote-and-enactment-deadline components inside the file to make things a bit more readable

* Feat/800: router config webpack chunk name tweak

* Feat/800: Lint issue fixed

* Feat/800: Fixed timing issue with get-[deadline]-timestamp tests

* Feat/800: Setting a system time in proposal-form-vote-and-enactment-deadline.spec.tsx that doesn't get affected by British Summer Time

* Feat/800: Skipping a cypress test as the newProposalButton no longer takes a user directly to a proposal form. Leaving it in the codebase as the test is very likely to be updated.

* Feat/800: Adding comment on why I've skipped a cypress test

Co-authored-by: Dexter <dexter@vega.xyz>
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
This commit is contained in:
Sam Keen 2022-09-23 11:10:13 +01:00 committed by GitHub
parent e3254564ae
commit 4ef8218267
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 3723 additions and 294 deletions

View File

@ -10,6 +10,7 @@ import {
formatNumber,
t,
} from '@vegaprotocol/react-helpers';
import { suitableForSyntaxHighlighter } from '@vegaprotocol/react-helpers';
import { RouteTitle } from '../../components/route-title';
import type {
NetworkParametersQuery,
@ -58,7 +59,7 @@ export const NetworkParameterRow = ({
}: {
row: NetworkParametersQuery_networkParameters;
}) => {
const isSyntaxRow = isJsonObject(value);
const isSyntaxRow = suitableForSyntaxHighlighter(value);
return (
<KeyValueTableRow
@ -85,14 +86,6 @@ export const NetworkParameterRow = ({
);
};
export const isJsonObject = (str: string) => {
try {
return JSON.parse(str) && Object.keys(JSON.parse(str)).length > 0;
} catch (e) {
return false;
}
};
export const NETWORK_PARAMETERS_QUERY = gql`
query NetworkParametersQuery {
networkParameters {

View File

@ -63,7 +63,10 @@ context(
.and('have.text', 'There are no enacted or rejected proposals');
});
it('should be able to see a connect wallet button - if vega wallet disconnected and new proposal button selected', function () {
// Skipping this test for now, the new proposal button no longer takes a user directly
// to a proposal form, instead it takes them to a page where they can select a proposal type.
// Keeping this test here for now as it can be repurposed to test the new proposal forms.
it.skip('should be able to see a connect wallet button - if vega wallet disconnected and new proposal button selected', function () {
cy.get(newProposalButton).should('be.visible').click();
cy.get(connectToVegaWalletButton)
.should('be.visible')

View File

@ -7,3 +7,4 @@ NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9
NX_ETHERSCAN_URL=https://ropsten.etherscan.io
NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions
NX_VEGA_EXPLORER_URL=https://dev.explorer.vega.xyz
NX_VEGA_DOCS_URL=https://docs.vega.xyz/docs/testnet

View File

@ -7,3 +7,4 @@ NX_ETHEREUM_PROVIDER_URL=https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9
NX_ETHERSCAN_URL=https://etherscan.io
NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions
NX_VEGA_EXPLORER_URL=https://explorer.vega.xyz
NX_VEGA_DOCS_URL=https://docs.vega.xyz/docs/mainnet

View File

@ -7,6 +7,7 @@ NX_VEGA_ENV=STAGNET3
NX_VEGA_NETWORKS='{"DEVNET":"https://dev.token.vega.xyz","STAGNET3":"https://stagnet3.token.vega.xyz","TESTNET":"https://token.fairground.wtf","MAINNET":"https://token.vega.xyz"}'
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json
NX_VEGA_EXPLORER_URL=https://staging3.explorer.vega.xyz
NX_VEGA_DOCS_URL=https://docs.vega.xyz/docs/testnet
# App flags
NX_EXPLORER_ASSETS=1

View File

@ -7,3 +7,4 @@ NX_ETHEREUM_PROVIDER_URL=https://ropsten.infura.io/v3/4f846e79e13f44d1b51bbd7ed9
NX_ETHERSCAN_URL=https://ropsten.etherscan.io
NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions
NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf
NX_VEGA_DOCS_URL=https://docs.vega.xyz/docs/testnet

View File

@ -48,6 +48,8 @@ export const ENV = {
// Environment
dsn: windowOrDefault('NX_SENTRY_DSN'),
urlConnect: TRUTHY.includes(windowOrDefault('NX_ETH_URL_CONNECT')),
explorerUrl: windowOrDefault('NX_VEGA_EXPLORER'),
docsUrl: windowOrDefault('NX_VEGA_DOCS_URL'),
ethWalletMnemonic: windowOrDefault('NX_ETH_WALLET_MNEMONIC'),
localProviderUrl: windowOrDefault('NX_LOCAL_PROVIDER_URL'),
flags: {

View File

@ -1,3 +1,3 @@
export * from './flags';
export * from './links';
export * from './network-params';
export * from './env';

View File

@ -11,4 +11,5 @@ export const Links = {
STAKING_GUIDE:
'https://docs.vega.xyz/docs/mainnet/concepts/vega-chain/#staking-on-vega',
GOVERNANCE_PAGE: 'https://vega.xyz/governance',
PROPOSALS_GUIDE: 'https://docs.vega.xyz/docs/mainnet/tutorials/proposals',
};

View File

@ -1,25 +0,0 @@
export const NetworkParams = {
ETHEREUM_CONFIG: 'blockchains.ethereumConfig',
REWARD_ASSET: 'reward.asset',
REWARD_PAYOUT_DURATION: 'reward.staking.delegation.payoutDelay',
GOV_UPDATE_MARKET_REQUIRED_MAJORITY:
'governance.proposal.updateMarket.requiredMajority',
GOV_UPDATE_MARKET_REQUIRED_PARTICIPATION:
'governance.proposal.updateMarket.requiredParticipation',
GOV_NEW_MARKET_REQUIRED_MAJORITY:
'governance.proposal.market.requiredMajority',
GOV_NEW_MARKET_REQUIRED_PARTICIPATION:
'governance.proposal.market.requiredParticipation',
GOV_ASSET_REQUIRED_MAJORITY: 'governance.proposal.asset.requiredMajority',
GOV_ASSET_REQUIRED_PARTICIPATION:
'governance.proposal.asset.requiredParticipation',
GOV_UPDATE_NET_PARAM_REQUIRED_MAJORITY:
'governance.proposal.updateNetParam.requiredMajority',
GOV_UPDATE_NET_PARAM_REQUIRED_PARTICIPATION:
'governance.proposal.updateNetParam.requiredParticipation',
GOV_FREEFORM_REQUIRED_PARTICIPATION:
'governance.proposal.freeform.requiredParticipation',
GOV_FREEFORM_REQUIRED_MAJORITY:
'governance.proposal.freeform.requiredMajority',
VALIDATOR_DELEGATION_MIN_AMOUNT: 'validators.delegation.minAmount',
};

View File

@ -630,8 +630,59 @@
"FilterProposalsDescription": "Filter by proposal ID or proposer ID",
"Freeform proposal": "Freeform proposal",
"NewProposal": "New proposal",
"MinProposalRequirements": "You must have at least 1 VEGA associated to make a proposal",
"ProposalTypeQuestion": "What type of proposal would you like to make?",
"NetworkParameterProposal": "Update network parameter proposal",
"NewMarketProposal": "New market proposal",
"UpdateMarketProposal": "Update market proposal",
"NewAssetProposal": "New asset proposal",
"NewFreeformProposal": "New freeform proposal",
"NewRawProposal": "New raw proposal",
"MinProposalRequirements": "You must have at least {{value}} VEGA associated to make a proposal",
"totalSupply": "Total Supply",
"viaWallet": "via wallet",
"viaContract": "via vesting"
"viaContract": "via vesting",
"ProposalDocsPrefix": "For guidance on how to make proposals, see",
"NetworkParameter": "Network parameter",
"NewMarket": "New market",
"UpdateMarket": "Update market",
"NewAsset": "New asset",
"Freeform": "Freeform",
"RawProposal": "Let me choose (raw proposal)",
"UseMin": "Use minimum",
"UseMax": "Use maximum",
"Proposal": "Proposal",
"ProposalRationale": "Proposal rationale",
"ProposalTitle": "Title",
"ProposalTitleText": "Tell people what you are proposing and why (100 characters or less)",
"ProposalsGuide": "proposals guide",
"ProposalDescription": "Description",
"ProposalDescriptionText": "Full justification for what you are proposing (20,000 characters or less). Markdown is recommended. When linking to external resources please use IPFS",
"ProposalTerms": "Proposal terms (JSON format)",
"ProposalTermsText": "For more information visit",
"ProposalReference": "Reference",
"ProposalVoteTitle": "Vote deadline",
"ProposalVoteAndEnactmentTitle": "Vote deadline and enactment",
"ProposalVoteDeadline": "Time till voting closes",
"ProposalEnactmentDeadline": "Time till enactment (after vote close)",
"ProposalValidationDeadline": "Time till ERC-20 asset validation. Maximum value is affected by the vote deadline.",
"ThisWillSetVotingDeadlineTo": "This will set the voting deadline to",
"ThisWillSetEnactmentDeadlineTo": "This will set the enactment date to",
"ThisWillSetValidationDeadlineTo": "This will set the validation deadline to",
"Hours": "hours",
"ThisWillAdd2MinutesToAllowTimeToConfirmInWallet": "Note: we add 2 minutes of extra time when you choose the minimum value. This gives you time to confirm the proposal in your wallet.",
"SelectAMarketToChange": "Select a market to change",
"MarketName": "Market name",
"MarketCode": "Market code",
"MarketId": "Market ID",
"ProposeNewMarketTerms": "terms.changes.newMarket (JSON format)",
"ProposeUpdateMarketTerms": "terms.updateMarket.changes (JSON format)",
"SelectAParameterToChange": "Select a parameter to change",
"SelectParameter": "Select parameter",
"SelectMarket": "Select market",
"CurrentValue": "Current value",
"NewProposedValue": "New proposed value",
"MoreProposalsInfo": "To see Explorer data on proposals visit",
"MoreNetParamsInfo": "To see Explorer data on network params visit",
"MoreMarketsInfo": "To see Explorer data on existing markets visit",
"MoreAssetsInfo": "To see Explorer data on existing assets visit"
}

View File

@ -6,8 +6,11 @@ import { useEnvironment } from '@vegaprotocol/environment';
import { Heading } from '../../components/heading';
import { SplashLoader } from '../../components/splash-loader';
import { ENV } from '../../config/env';
import type { RouteChildProps } from '../index';
import { useDocumentTitle } from '../../hooks/use-document-title';
const Contracts = () => {
const Contracts = ({ name }: RouteChildProps) => {
useDocumentTitle(name);
const { config } = useEthereumConfig();
const { ETHERSCAN_URL } = useEnvironment();

View File

@ -0,0 +1,8 @@
export * from './proposal-form-subheader';
export * from './proposal-form-min-requirements';
export * from './proposal-form-title';
export * from './proposal-form-description';
export * from './proposal-form-terms';
export * from './proposal-form-submit';
export * from './proposal-form-transaction-dialog';
export * from './proposal-form-vote-and-enactment-deadline';

View File

@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { ProposalFormDescription } from './proposal-form-description';
describe('Proposal Form Description', () => {
it('should display error text', () => {
const register = jest.fn();
render(
<ProposalFormDescription
registerField={register('proposalDescription')}
errorMessage="Error text"
/>
);
expect(screen.getByText('Error text')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { FormGroup, InputError, TextArea } from '@vegaprotocol/ui-toolkit';
import type { UseFormRegisterReturn } from 'react-hook-form';
interface ProposalFormDescriptionProps {
registerField: UseFormRegisterReturn<'proposalDescription'>;
errorMessage: string | undefined;
}
export const ProposalFormDescription = ({
registerField: register,
errorMessage,
}: ProposalFormDescriptionProps) => {
const { t } = useTranslation();
return (
<FormGroup
label={t('ProposalDescription')}
labelDescription={t('ProposalDescriptionText')}
labelFor="proposal-description"
>
<TextArea
id="proposal-description"
maxLength={20000}
className="min-h-[200px]"
hasError={Boolean(errorMessage)}
data-testid="proposal-description"
{...register}
/>
{errorMessage && <InputError intent="danger">{errorMessage}</InputError>}
</FormGroup>
);
};

View File

@ -0,0 +1,32 @@
import { render } from '@testing-library/react';
import { ProposalFormMinRequirements } from './proposal-form-min-requirements';
describe('ProposalFormMinRequirements', () => {
it('should render successfully with spam protection value, if larger', () => {
const { baseElement } = render(
<ProposalFormMinRequirements
minProposerBalance="1000000000000000000"
spamProtectionMin="2000000000000000000"
/>
);
expect(baseElement).toBeTruthy();
expect(baseElement).toHaveTextContent(
'You must have at least 2 VEGA associated to make a proposal'
);
});
it('should render successfully with min proposer value, if larger', () => {
const { baseElement } = render(
<ProposalFormMinRequirements
minProposerBalance="3000000000000000000"
spamProtectionMin="1000000000000000000"
/>
);
expect(baseElement).toBeTruthy();
expect(baseElement).toHaveTextContent(
'You must have at least 3 VEGA associated to make a proposal'
);
});
});

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import BigNumber from 'bignumber.js';
import { addDecimal } from '@vegaprotocol/react-helpers';
interface ProposalFormMinRequirementsProps {
minProposerBalance: string | undefined;
spamProtectionMin: string | undefined;
}
export const ProposalFormMinRequirements = ({
minProposerBalance,
spamProtectionMin,
}: ProposalFormMinRequirementsProps) => {
const { t } = useTranslation();
const minProposerBalanceFormatted =
minProposerBalance && new BigNumber(addDecimal(minProposerBalance, 18));
const spamProtectionMinFormatted =
spamProtectionMin && new BigNumber(addDecimal(spamProtectionMin, 18));
const larger =
Number(minProposerBalanceFormatted) > (spamProtectionMinFormatted || 0)
? minProposerBalanceFormatted
: spamProtectionMinFormatted;
return (
<p className="mb-4" data-testid="min-proposal-requirements">
{t('MinProposalRequirements', { value: larger })}
</p>
);
};

View File

@ -0,0 +1,9 @@
import type { ReactNode } from 'react';
interface ProposalFormSubheaderProps {
children: ReactNode;
}
export const ProposalFormSubheader = ({
children,
}: ProposalFormSubheaderProps) => <h2 className="mt-8 mb-4">{children}</h2>;

View File

@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@vegaprotocol/ui-toolkit';
interface ProposalFormSubmitProps {
isSubmitting: boolean;
}
export const ProposalFormSubmit = ({
isSubmitting,
}: ProposalFormSubmitProps) => {
const { t } = useTranslation();
return (
<div className="mt-10 my-20">
<Button
variant="primary"
type="submit"
data-testid="proposal-submit"
disabled={isSubmitting}
>
{isSubmitting ? t('Submitting') : t('Submit')} {t('Proposal')}
</Button>
</div>
);
};

View File

@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import { ProposalFormTerms } from './proposal-form-terms';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_DOCS_URL: 'https://docs.vega.xyz',
}),
}));
const renderComponent = () => {
const register = jest.fn();
render(
<ProposalFormTerms
registerField={register('proposalTerms')}
errorMessage="Error text"
/>
);
};
describe('Proposal Form Terms', () => {
it('should render', () => {
renderComponent();
expect(screen.getByTestId('proposal-terms')).toBeTruthy();
});
it('should display error text', () => {
renderComponent();
expect(screen.getByText('Error text')).toBeInTheDocument();
});
it('should render the generic docs link if no custom override', () => {
renderComponent();
expect(
screen.getByText('https://docs.vega.xyz/tutorials/proposals')
).toBeInTheDocument();
});
it('should render the custom docs link if provided', () => {
const register = jest.fn();
render(
<ProposalFormTerms
registerField={register('proposalTerms')}
errorMessage="Error text"
customDocLink="/custom"
/>
);
expect(
screen.getByText('https://docs.vega.xyz/tutorials/proposals/custom')
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
import {
FormGroup,
InputError,
Link,
TextArea,
} from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import type { UseFormRegisterReturn } from 'react-hook-form';
interface ProposalFormTermsProps {
registerField: UseFormRegisterReturn<'proposalTerms'>;
errorMessage: string | undefined;
labelOverride?: string;
customDocLink?: string;
}
export const ProposalFormTerms = ({
registerField: register,
errorMessage,
labelOverride,
customDocLink,
}: ProposalFormTermsProps) => {
const { VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
return (
<FormGroup
label={labelOverride || t('ProposalTerms')}
labelFor="proposal-terms"
>
{VEGA_DOCS_URL && (
<div className="mt-[-4px] mb-2 text-sm font-light">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals${customDocLink || ''}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals${customDocLink || ''}`}</Link>
</div>
)}
<TextArea
id="proposal-terms"
className="min-h-[200px]"
hasError={Boolean(errorMessage)}
data-testid="proposal-terms"
{...register}
/>
{errorMessage && <InputError intent="danger">{errorMessage}</InputError>}
</FormGroup>
);
};

View File

@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { ProposalFormTitle } from './proposal-form-title';
describe('Proposal Form Title', () => {
it('should display error text', () => {
const register = jest.fn();
render(
<ProposalFormTitle
registerField={register('proposalTitle')}
errorMessage="Error text"
/>
);
expect(screen.getByText('Error text')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import type { UseFormRegisterReturn } from 'react-hook-form';
interface ProposalFormTitleProps {
registerField: UseFormRegisterReturn<'proposalTitle'>;
errorMessage: string | undefined;
}
export const ProposalFormTitle = ({
registerField: register,
errorMessage,
}: ProposalFormTitleProps) => {
const { t } = useTranslation();
return (
<FormGroup
label={t('ProposalTitle')}
labelFor="proposal-title"
labelDescription={t('ProposalTitleText')}
>
<Input
id="proposal-title"
maxLength={100}
hasError={Boolean(errorMessage)}
data-testid="proposal-title"
{...register}
/>
{errorMessage && <InputError intent="danger">{errorMessage}</InputError>}
</FormGroup>
);
};

View File

@ -0,0 +1,29 @@
import {
getProposalDialogIcon,
getProposalDialogIntent,
getProposalDialogTitle,
} from '@vegaprotocol/governance';
import type { ProposalEvent_busEvents_event_Proposal } from '@vegaprotocol/governance';
import type { DialogProps } from '@vegaprotocol/wallet';
interface ProposalFormTransactionDialogProps {
finalizedProposal: ProposalEvent_busEvents_event_Proposal | null;
TransactionDialog: (props: DialogProps) => JSX.Element;
}
export const ProposalFormTransactionDialog = ({
finalizedProposal,
TransactionDialog,
}: ProposalFormTransactionDialogProps) => (
<div data-testid="proposal-transaction-dialog">
<TransactionDialog
title={getProposalDialogTitle(finalizedProposal?.state)}
intent={getProposalDialogIntent(finalizedProposal?.state)}
icon={getProposalDialogIcon(finalizedProposal?.state)}
>
{finalizedProposal?.rejectionReason ? (
<p>{finalizedProposal.rejectionReason}</p>
) : undefined}
</TransactionDialog>
</div>
);

View File

@ -0,0 +1,194 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { ProposalFormVoteAndEnactmentDeadline } from './proposal-form-vote-and-enactment-deadline';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2022-01-01T00:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
const minVoteDeadline = '1h0m0s';
const maxVoteDeadline = '5h0m0s';
const minEnactDeadline = '1h0m0s';
const maxEnactDeadline = '4h0m0s';
const renderComponent = () => {
const register = jest.fn();
render(
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline')}
voteErrorMessage={undefined}
voteMinClose={minVoteDeadline}
voteMaxClose={maxVoteDeadline}
enactmentRegister={register('proposalEnactmentDeadline')}
enactmentErrorMessage={undefined}
enactmentMinClose={minEnactDeadline}
enactmentMaxClose={maxEnactDeadline}
validationRequired={true}
validationRegister={register('proposalValidationDeadline')}
validationErrorMessage={undefined}
/>
);
};
describe('Proposal form vote, validation and enactment deadline', () => {
it('should render all component parts successfully', () => {
renderComponent();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-enactment-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-validation-deadline')).toBeTruthy();
});
it('should have the correct initial values', () => {
renderComponent();
const deadlineInput = screen.getByTestId('proposal-vote-deadline');
expect(deadlineInput).toHaveValue(1);
const enactmentDeadlineInput = screen.getByTestId(
'proposal-enactment-deadline'
);
expect(enactmentDeadlineInput).toHaveValue(1);
const validationDeadlineInput = screen.getByTestId(
'proposal-validation-deadline'
);
expect(validationDeadlineInput).toHaveValue(0);
});
it('should use the voting max and min values when the buttons are clicked', () => {
renderComponent();
const voteDeadlineInput = screen.getByTestId('proposal-vote-deadline');
const maxButton = screen.getByTestId('max-vote');
const minButton = screen.getByTestId('min-vote');
fireEvent.click(maxButton);
expect(voteDeadlineInput).toHaveValue(5);
fireEvent.click(minButton);
expect(voteDeadlineInput).toHaveValue(1);
});
it('should use the validation max and min values when the buttons are clicked', () => {
renderComponent();
const validationDeadlineInput = screen.getByTestId(
'proposal-validation-deadline'
);
const maxButton = screen.getByTestId('max-validation');
const minButton = screen.getByTestId('min-validation');
fireEvent.click(maxButton);
// Note: the validation max value is determined by the vote deadline max value,
// which will currently be the default value of 1
expect(validationDeadlineInput).toHaveValue(1);
fireEvent.click(minButton);
expect(validationDeadlineInput).toHaveValue(0);
});
it('should update the validation deadline max value when the vote deadline value changes', () => {
renderComponent();
const voteDeadlineInput = screen.getByTestId('proposal-vote-deadline');
const validationDeadlineInput = screen.getByTestId(
'proposal-validation-deadline'
);
const maxButton = screen.getByTestId('max-validation');
fireEvent.change(voteDeadlineInput, { target: { value: 2 } });
fireEvent.click(maxButton);
expect(validationDeadlineInput).toHaveValue(2);
});
it('should use the enactment max and min values when the buttons are clicked', () => {
renderComponent();
const enactmentDeadlineInput = screen.getByTestId(
'proposal-enactment-deadline'
);
const maxButton = screen.getByTestId('max-enactment');
const minButton = screen.getByTestId('min-enactment');
fireEvent.click(maxButton);
expect(enactmentDeadlineInput).toHaveValue(4);
fireEvent.click(minButton);
expect(enactmentDeadlineInput).toHaveValue(1);
});
it('should show the 2 mins extra message when the vote deadline is set to minimum', () => {
renderComponent();
const voteDeadlineInput = screen.getByTestId('proposal-vote-deadline');
const minButton = screen.getByTestId('min-vote');
fireEvent.click(minButton);
expect(voteDeadlineInput).toHaveValue(1);
expect(screen.getByTestId('voting-2-mins-extra')).toBeTruthy();
});
it('should show the 2 mins extra message when the validation deadline is set to minimum', () => {
renderComponent();
const validationDeadlineInput = screen.getByTestId(
'proposal-validation-deadline'
);
const minButton = screen.getByTestId('min-validation');
fireEvent.click(minButton);
expect(validationDeadlineInput).toHaveValue(0);
expect(screen.getByTestId('validation-2-mins-extra')).toBeTruthy();
});
it('should show the correct datetimes', () => {
renderComponent();
// Should be adding 2 mins to the vote deadline as the minimum is set by
// default, and we add 2 mins for wallet confirmation
expect(screen.getByTestId('voting-date')).toHaveTextContent(
'1/1/2022, 1:02:00 AM'
);
expect(screen.getByTestId('validation-date')).toHaveTextContent(
'1/1/2022, 12:02:00 AM'
);
expect(screen.getByTestId('enactment-date')).toHaveTextContent(
'1/1/2022, 2:00:00 AM'
);
});
it('should be updating every second, so show the correct datetimes when 30 seconds have passed', () => {
renderComponent();
act(() => {
jest.advanceTimersByTime(30000);
});
expect(screen.getByTestId('voting-date')).toHaveTextContent(
'1/1/2022, 1:02:30 AM'
);
expect(screen.getByTestId('validation-date')).toHaveTextContent(
'1/1/2022, 12:02:30 AM'
);
expect(screen.getByTestId('enactment-date')).toHaveTextContent(
'1/1/2022, 2:00:30 AM'
);
});
it('update the vote deadline date and the enactment deadline date when the vote deadline is changed', () => {
renderComponent();
const voteDeadlineInput = screen.getByTestId('proposal-vote-deadline');
fireEvent.change(voteDeadlineInput, { target: { value: 2 } });
expect(screen.getByTestId('voting-date')).toHaveTextContent(
'1/1/2022, 2:00:00 AM'
);
expect(screen.getByTestId('enactment-date')).toHaveTextContent(
'1/1/2022, 3:00:00 AM'
);
});
it('updates the validation deadline max and date when the vote deadline max is changed', () => {
renderComponent();
const voteDeadlineMinButton = screen.getByTestId('min-vote');
const voteDeadlineMaxButton = screen.getByTestId('max-vote');
const validationDeadlineMaxButton = screen.getByTestId('max-validation');
const validationDeadlineInput = screen.getByTestId(
'proposal-validation-deadline'
);
fireEvent.click(voteDeadlineMaxButton);
fireEvent.click(validationDeadlineMaxButton);
expect(screen.getByTestId('validation-date')).toHaveTextContent(
'1/1/2022, 5:00:00 AM'
);
expect(validationDeadlineInput).toHaveValue(5);
fireEvent.click(voteDeadlineMinButton);
expect(screen.getByTestId('validation-date')).toHaveTextContent(
'1/1/2022, 1:00:00 AM'
);
expect(validationDeadlineInput).toHaveValue(1);
});
});

View File

@ -0,0 +1,446 @@
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { parse as ISO8601Parse, toSeconds } from 'iso8601-duration';
import {
ButtonLink,
FormGroup,
Input,
InputError,
} from '@vegaprotocol/ui-toolkit';
import { addHours, addMinutes } from 'date-fns';
import { ProposalFormSubheader } from './proposal-form-subheader';
import type { UseFormRegisterReturn } from 'react-hook-form';
interface DeadlineProps {
vote: number;
enactment: number;
validation: number;
}
interface DeadlineDatesProps {
vote: Date;
enactment: Date;
validation: Date;
}
interface ValidationFormProps {
validationRegister:
| UseFormRegisterReturn<'proposalValidationDeadline'>
| undefined;
deadlines: DeadlineProps;
deadlineDates: DeadlineDatesProps;
updateValidationDeadlineAndDate: (hours: number) => void;
validationErrorMessage: string | undefined;
}
const ValidationForm = ({
validationRegister,
deadlines,
deadlineDates,
updateValidationDeadlineAndDate,
validationErrorMessage,
}: ValidationFormProps) => {
const { t } = useTranslation();
return (
<FormGroup
label={t('ProposalValidationDeadline')}
labelFor="proposal-validation-deadline"
>
<div className="flex items-center gap-2">
<div className="relative w-28">
<Input
{...validationRegister}
id="proposal-validation-deadline"
type="number"
value={deadlines.validation}
min={0}
max={deadlines.vote}
onChange={(e) => {
updateValidationDeadlineAndDate(Number(e.target.value));
}}
data-testid="proposal-validation-deadline"
className="pr-12"
/>
<span className="absolute right-2 bottom-1/2 translate-y-1/2 text-sm">
{t('Hours')}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<ButtonLink
data-testid="min-validation"
onClick={() => updateValidationDeadlineAndDate(0)}
>
{t('UseMin')}
</ButtonLink>
<ButtonLink
data-testid="max-validation"
onClick={() => updateValidationDeadlineAndDate(deadlines.vote)}
>
{t('UseMax')}
</ButtonLink>
</div>
</div>
{validationErrorMessage && (
<InputError intent="danger">{validationErrorMessage}</InputError>
)}
{deadlineDates.validation && (
<p className="mt-2 text-sm text-white">
<span className="font-light">
{t('ThisWillSetValidationDeadlineTo')}
</span>
<span data-testid="validation-date" className="pl-2">
{deadlineDates.validation?.toLocaleString()}
</span>
{deadlines.validation === 0 && (
<span
data-testid="validation-2-mins-extra"
className="block mt-4 font-light"
>
{t('ThisWillAdd2MinutesToAllowTimeToConfirmInWallet')}
</span>
)}
</p>
)}
</FormGroup>
);
};
interface EnactmentFormProps {
enactmentRegister:
| UseFormRegisterReturn<'proposalEnactmentDeadline'>
| undefined;
deadlines: DeadlineProps;
deadlineDates: DeadlineDatesProps;
updateEnactmentDeadlineAndDate: (hours: number) => void;
enactmentErrorMessage: string | undefined;
minEnactmentHours: number;
maxEnactmentHours: number;
}
const EnactmentForm = ({
enactmentRegister,
deadlines,
deadlineDates,
updateEnactmentDeadlineAndDate,
enactmentErrorMessage,
minEnactmentHours,
maxEnactmentHours,
}: EnactmentFormProps) => {
const { t } = useTranslation();
return (
<FormGroup
label={t('ProposalEnactmentDeadline')}
labelFor="proposal-enactment-deadline"
>
<div className="flex items-center gap-2">
<div className="relative w-28">
<Input
{...enactmentRegister}
id="proposal-enactment-deadline"
type="number"
value={deadlines.enactment}
min={minEnactmentHours}
max={maxEnactmentHours}
onChange={(e) => {
updateEnactmentDeadlineAndDate(Number(e.target.value));
}}
data-testid="proposal-enactment-deadline"
className="pr-12"
/>
<span className="absolute right-2 bottom-1/2 translate-y-1/2 text-sm">
{t('Hours')}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<ButtonLink
data-testid="min-enactment"
onClick={() => updateEnactmentDeadlineAndDate(minEnactmentHours)}
>
{t('UseMin')}
</ButtonLink>
<ButtonLink
data-testid="max-enactment"
onClick={() => updateEnactmentDeadlineAndDate(maxEnactmentHours)}
>
{t('UseMax')}
</ButtonLink>
</div>
</div>
{enactmentErrorMessage && (
<InputError intent="danger">{enactmentErrorMessage}</InputError>
)}
{deadlineDates.enactment && (
<p className="mt-2 text-sm text-white">
<span className="font-light">
{t('ThisWillSetEnactmentDeadlineTo')}
</span>
<span data-testid="enactment-date" className="pl-2">
{deadlineDates.enactment?.toLocaleString()}
</span>
</p>
)}
</FormGroup>
);
};
export interface ProposalFormVoteAndEnactmentDeadlineProps {
voteRegister: UseFormRegisterReturn<'proposalVoteDeadline'>;
voteErrorMessage: string | undefined;
voteMinClose: string;
voteMaxClose: string;
enactmentRequired?: boolean;
enactmentRegister?: UseFormRegisterReturn<'proposalEnactmentDeadline'>;
enactmentErrorMessage?: string;
enactmentMinClose?: string;
enactmentMaxClose?: string;
validationRequired?: boolean;
validationRegister?: UseFormRegisterReturn<'proposalValidationDeadline'>;
validationErrorMessage?: string;
}
export const ProposalFormVoteAndEnactmentDeadline = ({
voteRegister,
voteErrorMessage,
voteMinClose,
voteMaxClose,
enactmentRegister,
enactmentErrorMessage,
enactmentMinClose,
enactmentMaxClose,
validationRequired,
validationRegister,
validationErrorMessage,
}: ProposalFormVoteAndEnactmentDeadlineProps) => {
const {
minVoteSeconds,
maxVoteSeconds,
minEnactmentSeconds,
maxEnactmentSeconds,
} = useMemo(
() => ({
minVoteSeconds: toSeconds(
ISO8601Parse(`PT${voteMinClose.toUpperCase()}`)
),
maxVoteSeconds: toSeconds(
ISO8601Parse(`PT${voteMaxClose.toUpperCase()}`)
),
minEnactmentSeconds:
enactmentMinClose &&
toSeconds(ISO8601Parse(`PT${enactmentMinClose.toUpperCase()}`)),
maxEnactmentSeconds:
enactmentMaxClose &&
toSeconds(ISO8601Parse(`PT${enactmentMaxClose.toUpperCase()}`)),
}),
[voteMinClose, voteMaxClose, enactmentMinClose, enactmentMaxClose]
);
// As we're rounding to hours for the simplified deadline ui presently, if vote deadline
// is less than one hour, make it one hour.
const { minVoteHours, maxVoteHours, minEnactmentHours, maxEnactmentHours } =
useMemo(
() => ({
minVoteHours:
Math.floor(minVoteSeconds / 3600) > 1
? Math.floor(minVoteSeconds / 3600)
: 1,
maxVoteHours: Math.floor(maxVoteSeconds / 3600),
minEnactmentHours:
minEnactmentSeconds && Math.floor(minEnactmentSeconds / 3600) > 1
? Math.floor(minEnactmentSeconds / 3600)
: 1,
maxEnactmentHours:
maxEnactmentSeconds && Math.floor(maxEnactmentSeconds / 3600),
}),
[minVoteSeconds, maxVoteSeconds, minEnactmentSeconds, maxEnactmentSeconds]
);
const [deadlines, setDeadlines] = useState<DeadlineProps>({
vote: minVoteHours,
enactment: minEnactmentHours,
validation: 0,
});
const [deadlineDates, setDeadlineDates] = useState<DeadlineDatesProps>({
vote:
deadlines.vote === minVoteHours
? addHours(addMinutes(new Date(), 2), deadlines.vote)
: addHours(new Date(), deadlines.vote),
enactment: addHours(new Date(), deadlines.vote + deadlines.enactment),
validation:
deadlines.validation === 0
? addHours(addMinutes(new Date(), 2), deadlines.validation)
: addHours(new Date(), deadlines.validation),
});
useEffect(() => {
const interval = setInterval(() => {
setDeadlineDates((prev) => ({
...prev,
vote:
deadlines.vote === minVoteHours
? addHours(addMinutes(new Date(), 2), deadlines.vote)
: addHours(new Date(), deadlines.vote),
enactment: addHours(new Date(), deadlines.vote + deadlines.enactment),
validation:
deadlines.validation === 0
? addHours(addMinutes(new Date(), 2), deadlines.validation)
: addHours(new Date(), deadlines.validation),
}));
}, 1000);
return () => clearInterval(interval);
}, [deadlines, minVoteHours]);
const updateVoteDeadlineAndDate = (hours: number) => {
// Validation, when needed, can only happen within the voting period. Therefore, if the
// vote deadline is changed, the validation deadline must be changed to be within the
// new vote deadline.
setDeadlines((prev) => ({
...prev,
vote: hours,
validation: Math.min(prev.validation, hours),
}));
// If the vote deadline is set to minimum, add 2 mins to the date as we do
// this on submission to allow time to confirm in the wallet. Amending the
// vote deadline also changes the enactment date and potentially the validation
// date.
// The validation deadline date cannot be after the vote deadline date. Therefore,
// if the vote deadline is changed, the validation deadline must potentially
// be changed to be within the new vote deadline.
setDeadlineDates((prev) => ({
...prev,
vote:
hours === minVoteHours
? addHours(addMinutes(new Date(), 2), hours)
: addHours(new Date(), hours),
enactment: addHours(new Date(), hours + deadlines.enactment),
validation: addHours(new Date(), Math.min(hours, deadlines.validation)),
}));
};
const updateEnactmentDeadlineAndDate = (hours: number) => {
setDeadlines((prev) => ({
...prev,
enactment: hours,
}));
setDeadlineDates((prev) => ({
...prev,
enactment: addHours(deadlineDates.vote, hours),
}));
};
const updateValidationDeadlineAndDate = (hours: number) => {
setDeadlines((prev) => ({
...prev,
validation: hours,
}));
setDeadlineDates((prev) => ({
...prev,
validation:
hours === 0
? addHours(addMinutes(new Date(), 2), hours)
: addHours(new Date(), hours),
}));
};
const { t } = useTranslation();
return (
<>
<ProposalFormSubheader>
{enactmentRegister && enactmentMinClose && enactmentMaxClose
? t('ProposalVoteAndEnactmentTitle')
: t('ProposalVoteTitle')}
</ProposalFormSubheader>
<FormGroup
label={t('ProposalVoteDeadline')}
labelFor="proposal-vote-deadline"
>
<div className="flex items-center gap-2">
<div className="relative w-28">
<Input
{...voteRegister}
id="proposal-vote-deadline"
type="number"
value={deadlines.vote}
min={minVoteHours}
max={maxVoteHours}
onChange={(e) => {
updateVoteDeadlineAndDate(Number(e.target.value));
}}
data-testid="proposal-vote-deadline"
className="pr-12"
/>
<span className="absolute right-2 bottom-1/2 translate-y-1/2 text-sm">
{t('Hours')}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<ButtonLink
data-testid="min-vote"
onClick={() => updateVoteDeadlineAndDate(minVoteHours)}
>
{t('UseMin')}
</ButtonLink>
<ButtonLink
data-testid="max-vote"
onClick={() => updateVoteDeadlineAndDate(maxVoteHours)}
>
{t('UseMax')}
</ButtonLink>
</div>
</div>
{voteErrorMessage && (
<InputError intent="danger">{voteErrorMessage}</InputError>
)}
{deadlineDates.vote && (
<p className="mt-2 text-sm text-white">
<span className="font-light">
{t('ThisWillSetVotingDeadlineTo')}
</span>
<span data-testid="voting-date" className="pl-2">
{deadlineDates.vote?.toLocaleString()}
</span>
{deadlines.vote === minVoteHours && (
<span
data-testid="voting-2-mins-extra"
className="block mt-4 font-light"
>
{t('ThisWillAdd2MinutesToAllowTimeToConfirmInWallet')}
</span>
)}
</p>
)}
</FormGroup>
{validationRequired && (
<ValidationForm
validationRegister={validationRegister}
deadlines={deadlines}
deadlineDates={deadlineDates}
updateValidationDeadlineAndDate={updateValidationDeadlineAndDate}
validationErrorMessage={validationErrorMessage}
/>
)}
{enactmentMinClose && enactmentMaxClose && maxEnactmentHours && (
<EnactmentForm
enactmentRegister={enactmentRegister}
deadlines={deadlines}
deadlineDates={deadlineDates}
updateEnactmentDeadlineAndDate={updateEnactmentDeadlineAndDate}
enactmentErrorMessage={enactmentErrorMessage}
minEnactmentHours={minEnactmentHours}
maxEnactmentHours={maxEnactmentHours}
/>
)}
</>
);
};

View File

@ -1,7 +1,6 @@
import { useNetworkParams } from '@vegaprotocol/react-helpers';
import { useNetworkParams, NetworkParams } from '@vegaprotocol/react-helpers';
import React from 'react';
import { NetworkParams } from '../../../config';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { BigNumber } from '../../../lib/bignumber';
import { addDecimal } from '../../../lib/decimals';
@ -16,63 +15,63 @@ const useProposalNetworkParams = ({
}: {
proposal: ProposalFields;
}) => {
const { data, loading } = useNetworkParams([
NetworkParams.GOV_UPDATE_MARKET_REQUIRED_MAJORITY,
NetworkParams.GOV_UPDATE_MARKET_REQUIRED_PARTICIPATION,
NetworkParams.GOV_NEW_MARKET_REQUIRED_MAJORITY,
NetworkParams.GOV_NEW_MARKET_REQUIRED_PARTICIPATION,
NetworkParams.GOV_ASSET_REQUIRED_MAJORITY,
NetworkParams.GOV_ASSET_REQUIRED_PARTICIPATION,
NetworkParams.GOV_UPDATE_NET_PARAM_REQUIRED_MAJORITY,
NetworkParams.GOV_UPDATE_NET_PARAM_REQUIRED_PARTICIPATION,
NetworkParams.GOV_FREEFORM_REQUIRED_MAJORITY,
NetworkParams.GOV_FREEFORM_REQUIRED_PARTICIPATION,
const { params } = useNetworkParams([
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredParticipation,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_market_requiredParticipation,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_asset_requiredParticipation,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredParticipation,
NetworkParams.governance_proposal_freeform_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredParticipation,
]);
if (loading || !data) {
if (!params) {
return {
requiredMajority: new BigNumber(1),
requiredParticipation: new BigNumber(1),
};
}
const [
updateMarketMajority,
updateMarketParticipation,
newMarketMajority,
newMarketParticipation,
assetMajority,
assetParticipation,
paramMajority,
paramParticipation,
freeformMajority,
freeformParticipation,
] = data;
switch (proposal.terms.change.__typename) {
case 'UpdateMarket':
return {
requiredMajority: updateMarketMajority,
requiredParticipation: new BigNumber(updateMarketParticipation),
requiredMajority:
params.governance_proposal_updateMarket_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_updateMarket_requiredParticipation
),
};
case 'UpdateNetworkParameter':
return {
requiredMajority: paramMajority,
requiredParticipation: new BigNumber(paramParticipation),
requiredMajority:
params.governance_proposal_updateNetParam_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_updateNetParam_requiredParticipation
),
};
case 'NewAsset':
return {
requiredMajority: assetMajority,
requiredParticipation: new BigNumber(assetParticipation),
requiredMajority: params.governance_proposal_asset_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_asset_requiredParticipation
),
};
case 'NewMarket':
return {
requiredMajority: newMarketMajority,
requiredParticipation: new BigNumber(newMarketParticipation),
requiredMajority: params.governance_proposal_market_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_market_requiredParticipation
),
};
case 'NewFreeform':
return {
requiredMajority: freeformMajority,
requiredParticipation: freeformParticipation,
requiredMajority: params.governance_proposal_freeform_requiredMajority,
requiredParticipation: new BigNumber(
params.governance_proposal_freeform_requiredParticipation
),
};
default:
throw new Error('Unknown proposal type');

View File

@ -0,0 +1,4 @@
export {
ProposeFreeform,
ProposeFreeform as default,
} from './propose-freeform';

View File

@ -0,0 +1,109 @@
import { render, screen, waitFor } from '@testing-library/react';
import { ProposeFreeform } from './propose-freeform';
import { MockedProvider } from '@apollo/client/testing';
import { mockWalletContext } from '../../test-helpers/mocks';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MemoryRouter as Router } from 'react-router-dom';
import { gql } from '@apollo/client';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
import type { MockedResponse } from '@apollo/client/testing';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_DOCS_URL: 'https://docs.vega.xyz',
}),
}));
const updateMarketNetworkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: gql`
query NetworkParams {
networkParameters {
key
value
}
}
`,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'governance.proposal.freeform.maxClose',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.freeform.maxEnact',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.freeform.minClose',
value: '1h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.freeform.minEnact',
value: '2h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.freeform.minProposerBalance',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1000000000000000000',
},
],
},
},
};
const renderComponent = () =>
render(
<Router>
<MockedProvider mocks={[updateMarketNetworkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposeFreeform />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
// Note: form submission is tested in propose-raw.spec.tsx. Reusable form
// components are tested in their own directory.
describe('Propose Freeform', () => {
it('should render successfully', async () => {
const { baseElement } = renderComponent();
expect(baseElement).toBeTruthy();
});
it('should render the title', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('New freeform proposal')).toBeInTheDocument()
);
});
it('should render the form components', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('freeform-proposal-form')).toBeTruthy()
);
expect(screen.getByTestId('min-proposal-requirements')).toBeTruthy();
expect(screen.getByTestId('proposal-docs-link')).toBeTruthy();
expect(screen.getByTestId('proposal-title')).toBeTruthy();
expect(screen.getByTestId('proposal-description')).toBeTruthy();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-submit')).toBeTruthy();
expect(screen.getByTestId('proposal-transaction-dialog')).toBeTruthy();
});
});

View File

@ -0,0 +1,135 @@
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
getClosingTimestamp,
useProposalSubmit,
} from '@vegaprotocol/governance';
import { useEnvironment } from '@vegaprotocol/environment';
import {
ProposalFormSubheader,
ProposalFormMinRequirements,
ProposalFormTitle,
ProposalFormDescription,
ProposalFormSubmit,
ProposalFormTransactionDialog,
ProposalFormVoteAndEnactmentDeadline,
} from '../../components/propose';
import { AsyncRenderer, Link } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
import { useNetworkParams, NetworkParams } from '@vegaprotocol/react-helpers';
export interface FreeformProposalFormFields {
proposalVoteDeadline: string;
proposalTitle: string;
proposalDescription: string;
proposalTerms: string;
proposalReference: string;
}
const docsLink = 'freeform-proposal';
export const ProposeFreeform = () => {
const { params, loading, error } = useNetworkParams([
NetworkParams.governance_proposal_freeform_minClose,
NetworkParams.governance_proposal_freeform_maxClose,
NetworkParams.governance_proposal_freeform_minProposerBalance,
NetworkParams.spam_protection_proposal_min_tokens,
]);
const { VEGA_DOCS_URL, VEGA_EXPLORER_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<FreeformProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const onSubmit = async (fields: FreeformProposalFormFields) => {
await submit({
rationale: {
title: fields.proposalTitle,
description: fields.proposalDescription,
},
terms: {
newFreeform: {},
closingTimestamp: getClosingTimestamp(fields.proposalVoteDeadline),
},
});
};
return (
<AsyncRenderer loading={loading} error={error} data={params}>
<Heading title={t('NewFreeformProposal')} />
<VegaWalletContainer>
{() => (
<>
<ProposalFormMinRequirements
minProposerBalance={
params.governance_proposal_freeform_minProposerBalance
}
spamProtectionMin={params.spam_protection_proposal_min_tokens}
/>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals/${docsLink}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals/${docsLink}`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreProposalsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/governance`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/governance`}</Link>
</p>
)}
<div data-testid="freeform-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<ProposalFormSubheader>
{t('ProposalRationale')}
</ProposalFormSubheader>
<ProposalFormTitle
registerField={register('proposalTitle', {
required: t('Required'),
})}
errorMessage={errors?.proposalTitle?.message}
/>
<ProposalFormDescription
registerField={register('proposalDescription', {
required: t('Required'),
})}
errorMessage={errors?.proposalDescription?.message}
/>
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline', {
required: t('Required'),
})}
voteErrorMessage={errors?.proposalVoteDeadline?.message}
voteMinClose={params.governance_proposal_freeform_minClose}
voteMaxClose={params.governance_proposal_freeform_maxClose}
/>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</AsyncRenderer>
);
};

View File

@ -0,0 +1,4 @@
export {
ProposeNetworkParameter,
ProposeNetworkParameter as default,
} from './propose-network-parameter';

View File

@ -0,0 +1,141 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ProposeNetworkParameter } from './propose-network-parameter';
import { MockedProvider } from '@apollo/client/testing';
import { mockWalletContext } from '../../test-helpers/mocks';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MemoryRouter as Router } from 'react-router-dom';
import { gql } from '@apollo/client';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
import type { MockedResponse } from '@apollo/client/testing';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_DOCS_URL: 'https://docs.vega.xyz',
}),
}));
const updateMarketNetworkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: gql`
query NetworkParams {
networkParameters {
key
value
}
}
`,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateNetParam.maxClose',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateNetParam.maxEnact',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateNetParam.minClose',
value: '1h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateNetParam.minEnact',
value: '2h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateNetParam.minProposerBalance',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1000000000000000000',
},
],
},
},
};
const renderComponent = () =>
render(
<Router>
<MockedProvider mocks={[updateMarketNetworkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposeNetworkParameter />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
// Note: form submission is tested in propose-raw.spec.tsx. Reusable form
// components are tested in their own directory.
describe('Propose Network Parameter', () => {
it('should render successfully', async () => {
const { baseElement } = renderComponent();
await expect(baseElement).toBeTruthy();
});
it('should render the correct title', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('Update network parameter proposal')).toBeTruthy()
);
});
it('should render the form components', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('network-parameter-proposal-form')).toBeTruthy()
);
expect(screen.getByTestId('min-proposal-requirements')).toBeTruthy();
expect(screen.getByTestId('proposal-docs-link')).toBeTruthy();
expect(screen.getByTestId('proposal-title')).toBeTruthy();
expect(screen.getByTestId('proposal-description')).toBeTruthy();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-enactment-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-submit')).toBeTruthy();
expect(screen.getByTestId('proposal-transaction-dialog')).toBeTruthy();
});
it('should render the network param select element with no initial value', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('proposal-parameter-select')).toHaveValue('')
);
});
it('should render the current param value and a new value input when the network param select element is changed', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('proposal-parameter-select')).toHaveValue('')
);
fireEvent.change(screen.getByTestId('proposal-parameter-select'), {
target: {
value: 'spam_protection_proposal_min_tokens',
},
});
expect(screen.getByTestId('proposal-parameter-select')).toHaveValue(
'spam_protection_proposal_min_tokens'
);
expect(
screen.getByTestId('selected-proposal-param-current-value')
).toHaveValue('1000000000000000000');
expect(
screen.getByTestId('selected-proposal-param-new-value')
).toBeVisible();
});
});

View File

@ -0,0 +1,280 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import {
suitableForSyntaxHighlighter,
useNetworkParams,
} from '@vegaprotocol/react-helpers';
import {
useProposalSubmit,
getClosingTimestamp,
getEnactmentTimestamp,
} from '@vegaprotocol/governance';
import { useEnvironment } from '@vegaprotocol/environment';
import {
ProposalFormSubheader,
ProposalFormMinRequirements,
ProposalFormTitle,
ProposalFormDescription,
ProposalFormSubmit,
ProposalFormTransactionDialog,
ProposalFormVoteAndEnactmentDeadline,
} from '../../components/propose';
import {
AsyncRenderer,
FormGroup,
Input,
InputError,
Link,
Select,
SyntaxHighlighter,
TextArea,
} from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
interface SelectedNetworkParamCurrentValueProps {
value: string;
}
const SelectedNetworkParamCurrentValue = ({
value,
}: SelectedNetworkParamCurrentValueProps) => {
const { t } = useTranslation();
return (
<div className="mb-4">
<p className="text-sm text-white">{t('CurrentValue')}</p>
{suitableForSyntaxHighlighter(value) ? (
<SyntaxHighlighter data={JSON.parse(value)} />
) : (
<Input
value={value}
data-testid="selected-proposal-param-current-value"
readOnly
/>
)}
</div>
);
};
export interface NetworkParameterProposalFormFields {
proposalVoteDeadline: string;
proposalEnactmentDeadline: string;
proposalTitle: string;
proposalDescription: string;
proposalNetworkParameterKey: string;
proposalNetworkParameterValue: string;
proposalReference: string;
}
const docsLink = '/network-parameter-proposal';
export const ProposeNetworkParameter = () => {
const [selectedNetworkParam, setSelectedNetworkParam] = useState<
string | undefined
>(undefined);
const {
params,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams();
const { VEGA_EXPLORER_URL, VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<NetworkParameterProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const selectedParamEntry = params
? Object.entries(params).find(([key]) => key === selectedNetworkParam)
: null;
const onSubmit = async (fields: NetworkParameterProposalFormFields) => {
const acutalNetworkParamKey = fields.proposalNetworkParameterKey
.split('_')
.join('.');
await submit({
rationale: {
title: fields.proposalTitle,
description: fields.proposalDescription,
},
terms: {
updateNetworkParameter: {
changes: {
key: acutalNetworkParamKey,
value: fields.proposalNetworkParameterValue,
},
},
closingTimestamp: getClosingTimestamp(fields.proposalVoteDeadline),
enactmentTimestamp: getEnactmentTimestamp(
fields.proposalVoteDeadline,
fields.proposalEnactmentDeadline
),
},
});
};
return (
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={params}
>
<Heading title={t('NetworkParameterProposal')} />
<VegaWalletContainer>
{() => (
<>
<ProposalFormMinRequirements
minProposerBalance={
params.governance_proposal_updateNetParam_minProposerBalance
}
spamProtectionMin={params.spam_protection_proposal_min_tokens}
/>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreNetParamsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/network-parameters`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/network-parameters`}</Link>
</p>
)}
<div data-testid="network-parameter-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<ProposalFormSubheader>
{t('ProposalRationale')}
</ProposalFormSubheader>
<ProposalFormTitle
registerField={register('proposalTitle', {
required: t('Required'),
})}
errorMessage={errors?.proposalTitle?.message}
/>
<ProposalFormDescription
registerField={register('proposalDescription', {
required: t('Required'),
})}
errorMessage={errors?.proposalDescription?.message}
/>
<ProposalFormSubheader>
{t('SelectAParameterToChange')}
</ProposalFormSubheader>
<FormGroup
label={t('SelectAParameterToChange')}
labelFor="proposal-parameter-key"
hideLabel={true}
>
<Select
data-testid="proposal-parameter-select"
id="proposal-parameter-key"
{...register('proposalNetworkParameterKey', {
required: t('Required'),
})}
onChange={(e) => setSelectedNetworkParam(e.target.value)}
value={selectedNetworkParam}
>
<option value="">{t('SelectParameter')}</option>
{Object.keys(params).map((key) => {
const actualKey = key.split('_').join('.');
return (
<option key={key} value={key}>
{actualKey}
</option>
);
})}
</Select>
{errors?.proposalNetworkParameterKey?.message && (
<InputError intent="danger">
{errors?.proposalNetworkParameterKey?.message}
</InputError>
)}
</FormGroup>
{selectedNetworkParam && (
<div className="mt-[-10px]">
{selectedParamEntry && (
<SelectedNetworkParamCurrentValue
value={selectedParamEntry[1]}
/>
)}
<FormGroup
label={t('NewProposedValue')}
labelFor="proposal-parameter-new-value"
>
<TextArea
data-testid="selected-proposal-param-new-value"
id="proposal-parameter-new-value"
{...register('proposalNetworkParameterValue', {
required: t('Required'),
})}
/>
{errors?.proposalNetworkParameterValue?.message && (
<InputError intent="danger">
{errors?.proposalNetworkParameterValue?.message}
</InputError>
)}
</FormGroup>
</div>
)}
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline', {
required: t('Required'),
})}
voteErrorMessage={errors?.proposalVoteDeadline?.message}
voteMinClose={
params.governance_proposal_updateNetParam_minClose
}
voteMaxClose={
params.governance_proposal_updateNetParam_maxClose
}
enactmentRegister={register('proposalEnactmentDeadline', {
required: t('Required'),
})}
enactmentErrorMessage={
errors?.proposalEnactmentDeadline?.message
}
enactmentMinClose={
params.governance_proposal_updateNetParam_minEnact
}
enactmentMaxClose={
params.governance_proposal_updateNetParam_maxEnact
}
/>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</AsyncRenderer>
);
};

View File

@ -0,0 +1,4 @@
export {
ProposeNewAsset,
ProposeNewAsset as default,
} from './propose-new-asset';

View File

@ -0,0 +1,112 @@
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { mockWalletContext } from '../../test-helpers/mocks';
import { ProposeNewAsset } from './propose-new-asset';
import { gql } from '@apollo/client';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
import type { MockedResponse } from '@apollo/client/testing';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_DOCS_URL: 'https://docs.vega.xyz',
}),
}));
const updateMarketNetworkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: gql`
query NetworkParams {
networkParameters {
key
value
}
}
`,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'governance.proposal.asset.maxClose',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.asset.maxEnact',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.asset.minClose',
value: '1h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.asset.minEnact',
value: '2h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.asset.minProposerBalance',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1000000000000000000',
},
],
},
},
};
const renderComponent = () =>
render(
<Router>
<MockedProvider mocks={[updateMarketNetworkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposeNewAsset />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
// Note: form submission is tested in propose-raw.spec.tsx. Reusable form
// components are tested in their own directory.
describe('Propose New Asset', () => {
it('should render successfully', async () => {
const { baseElement } = renderComponent();
await expect(baseElement).toBeTruthy();
});
it('should render the title', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('New asset proposal')).toBeTruthy()
);
});
it('should render the form components', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('new-asset-proposal-form')).toBeTruthy()
);
expect(screen.getByTestId('min-proposal-requirements')).toBeTruthy();
expect(screen.getByTestId('proposal-docs-link')).toBeTruthy();
expect(screen.getByTestId('proposal-title')).toBeTruthy();
expect(screen.getByTestId('proposal-description')).toBeTruthy();
expect(screen.getByTestId('proposal-terms')).toBeTruthy();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-validation-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-enactment-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-submit')).toBeTruthy();
expect(screen.getByTestId('proposal-transaction-dialog')).toBeTruthy();
});
});

View File

@ -0,0 +1,196 @@
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
useProposalSubmit,
getClosingTimestamp,
getEnactmentTimestamp,
getValidationTimestamp,
} from '@vegaprotocol/governance';
import { useEnvironment } from '@vegaprotocol/environment';
import {
ProposalFormMinRequirements,
ProposalFormTitle,
ProposalFormDescription,
ProposalFormTerms,
ProposalFormSubmit,
ProposalFormTransactionDialog,
ProposalFormSubheader,
ProposalFormVoteAndEnactmentDeadline,
} from '../../components/propose';
import { AsyncRenderer, Link } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
import { NetworkParams, useNetworkParams } from '@vegaprotocol/react-helpers';
export interface NewAssetProposalFormFields {
proposalVoteDeadline: string;
proposalEnactmentDeadline: string;
proposalValidationDeadline: string;
proposalTitle: string;
proposalDescription: string;
proposalTerms: string;
proposalReference: string;
}
const docsLink = '/new-asset-proposal';
export const ProposeNewAsset = () => {
const {
params,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_asset_minClose,
NetworkParams.governance_proposal_asset_maxClose,
NetworkParams.governance_proposal_asset_minEnact,
NetworkParams.governance_proposal_asset_maxEnact,
NetworkParams.governance_proposal_asset_minProposerBalance,
NetworkParams.spam_protection_proposal_min_tokens,
]);
const { VEGA_EXPLORER_URL, VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<NewAssetProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const onSubmit = async (fields: NewAssetProposalFormFields) => {
await submit({
rationale: {
title: fields.proposalTitle,
description: fields.proposalDescription,
},
terms: {
newAsset: {
...JSON.parse(fields.proposalTerms),
},
closingTimestamp: getClosingTimestamp(fields.proposalVoteDeadline),
enactmentTimestamp: getEnactmentTimestamp(
fields.proposalVoteDeadline,
fields.proposalEnactmentDeadline
),
validationTimestamp: getValidationTimestamp(
fields.proposalValidationDeadline
),
},
});
};
return (
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={params}
>
<Heading title={t('NewAssetProposal')} />
<VegaWalletContainer>
{() => (
<>
<ProposalFormMinRequirements
minProposerBalance={
params.governance_proposal_asset_minProposerBalance
}
spamProtectionMin={params.spam_protection_proposal_min_tokens}
/>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreAssetsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/assets`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/assets`}</Link>
</p>
)}
<div data-testid="new-asset-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<ProposalFormSubheader>
{t('ProposalRationale')}
</ProposalFormSubheader>
<ProposalFormTitle
registerField={register('proposalTitle', {
required: t('Required'),
})}
errorMessage={errors?.proposalTitle?.message}
/>
<ProposalFormDescription
registerField={register('proposalDescription', {
required: t('Required'),
})}
errorMessage={errors?.proposalDescription?.message}
/>
<ProposalFormSubheader>{t('NewAsset')}</ProposalFormSubheader>
<ProposalFormTerms
registerField={register('proposalTerms', {
required: t('Required'),
validate: {
validateJson: (value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return t('Must be valid JSON');
}
},
},
})}
labelOverride={'Terms.newAsset (JSON format)'}
errorMessage={errors?.proposalTerms?.message}
customDocLink={docsLink}
/>
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline', {
required: t('Required'),
})}
voteErrorMessage={errors?.proposalVoteDeadline?.message}
voteMinClose={params.governance_proposal_asset_minClose}
voteMaxClose={params.governance_proposal_asset_maxClose}
enactmentRegister={register('proposalEnactmentDeadline', {
required: t('Required'),
})}
enactmentErrorMessage={
errors?.proposalEnactmentDeadline?.message
}
enactmentMinClose={params.governance_proposal_asset_minEnact}
enactmentMaxClose={params.governance_proposal_asset_maxEnact}
validationRequired={true}
validationRegister={register('proposalValidationDeadline', {
required: t('Required'),
})}
validationErrorMessage={
errors?.proposalValidationDeadline?.message
}
/>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</AsyncRenderer>
);
};

View File

@ -0,0 +1,4 @@
export {
ProposeNewMarket,
ProposeNewMarket as default,
} from './propose-new-market';

View File

@ -0,0 +1,104 @@
import { gql } from '@apollo/client';
import { render, screen, waitFor } from '@testing-library/react';
import { ProposeNewMarket } from './propose-new-market';
import { MockedProvider } from '@apollo/client/testing';
import { mockWalletContext } from '../../test-helpers/mocks';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { BrowserRouter as Router } from 'react-router-dom';
import type { MockedResponse } from '@apollo/client/testing';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_DOCS_URL: 'https://docs.vega.xyz',
}),
}));
const newMarketNetworkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: gql`
query NetworkParams {
networkParameters {
key
value
}
}
`,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'governance.proposal.market.maxClose',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.market.maxEnact',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.market.minClose',
value: '1h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.market.minEnact',
value: '2h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.market.minProposerBalance',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1000000000000000000',
},
],
},
},
};
const renderComponent = () =>
render(
<Router>
<MockedProvider mocks={[newMarketNetworkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposeNewMarket />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
// Note: form submission is tested in propose-raw.spec.tsx. Reusable form
// components are tested in their own directory.
describe('Propose New Market', () => {
it('should render successfully', async () => {
const { baseElement } = renderComponent();
await expect(baseElement).toBeTruthy();
});
it('should render the form components', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('new-market-proposal-form')).toBeTruthy()
);
expect(screen.getByTestId('min-proposal-requirements')).toBeTruthy();
expect(screen.getByTestId('proposal-docs-link')).toBeTruthy();
expect(screen.getByTestId('proposal-title')).toBeTruthy();
expect(screen.getByTestId('proposal-description')).toBeTruthy();
expect(screen.getByTestId('proposal-terms')).toBeTruthy();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-enactment-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-submit')).toBeTruthy();
expect(screen.getByTestId('proposal-transaction-dialog')).toBeTruthy();
});
});

View File

@ -0,0 +1,184 @@
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
useProposalSubmit,
getClosingTimestamp,
getEnactmentTimestamp,
} from '@vegaprotocol/governance';
import { useEnvironment } from '@vegaprotocol/environment';
import {
ProposalFormMinRequirements,
ProposalFormTitle,
ProposalFormDescription,
ProposalFormTerms,
ProposalFormSubmit,
ProposalFormTransactionDialog,
ProposalFormSubheader,
ProposalFormVoteAndEnactmentDeadline,
} from '../../components/propose';
import { AsyncRenderer, Link } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
import { NetworkParams, useNetworkParams } from '@vegaprotocol/react-helpers';
export interface NewMarketProposalFormFields {
proposalVoteDeadline: string;
proposalEnactmentDeadline: string;
proposalTitle: string;
proposalDescription: string;
proposalTerms: string;
proposalReference: string;
}
const docsLink = '/new-market-proposal';
export const ProposeNewMarket = () => {
const {
params,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_maxClose,
NetworkParams.governance_proposal_market_minClose,
NetworkParams.governance_proposal_market_maxEnact,
NetworkParams.governance_proposal_market_minEnact,
NetworkParams.governance_proposal_market_minProposerBalance,
NetworkParams.spam_protection_proposal_min_tokens,
]);
const { VEGA_EXPLORER_URL, VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<NewMarketProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const onSubmit = async (fields: NewMarketProposalFormFields) => {
await submit({
rationale: {
title: fields.proposalTitle,
description: fields.proposalDescription,
},
terms: {
newMarket: {
...JSON.parse(fields.proposalTerms),
},
closingTimestamp: getClosingTimestamp(fields.proposalVoteDeadline),
enactmentTimestamp: getEnactmentTimestamp(
fields.proposalVoteDeadline,
fields.proposalEnactmentDeadline
),
},
});
};
return (
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={params}
>
<Heading title={t('NewMarketProposal')} />
<VegaWalletContainer>
{() => (
<>
<ProposalFormMinRequirements
minProposerBalance={
params.governance_proposal_market_minProposerBalance
}
spamProtectionMin={params.spam_protection_proposal_min_tokens}
/>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals/${docsLink}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals/${docsLink}`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreMarketsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/markets`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/markets`}</Link>
</p>
)}
<div data-testid="new-market-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<ProposalFormSubheader>
{t('ProposalRationale')}
</ProposalFormSubheader>
<ProposalFormTitle
registerField={register('proposalTitle', {
required: t('Required'),
})}
errorMessage={errors?.proposalTitle?.message}
/>
<ProposalFormDescription
registerField={register('proposalDescription', {
required: t('Required'),
})}
errorMessage={errors?.proposalDescription?.message}
/>
<ProposalFormSubheader>{t('NewMarket')}</ProposalFormSubheader>
<ProposalFormTerms
registerField={register('proposalTerms', {
required: t('Required'),
validate: {
validateJson: (value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return t('Must be valid JSON');
}
},
},
})}
labelOverride={'Terms.newMarket (JSON format)'}
errorMessage={errors?.proposalTerms?.message}
customDocLink={docsLink}
/>
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline', {
required: t('Required'),
})}
voteErrorMessage={errors?.proposalVoteDeadline?.message}
voteMinClose={params.governance_proposal_market_minClose}
voteMaxClose={params.governance_proposal_market_maxClose}
enactmentRegister={register('proposalEnactmentDeadline', {
required: t('Required'),
})}
enactmentErrorMessage={
errors?.proposalEnactmentDeadline?.message
}
enactmentMinClose={params.governance_proposal_market_minEnact}
enactmentMaxClose={params.governance_proposal_market_maxEnact}
/>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</AsyncRenderer>
);
};

View File

@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Propose } from './propose';
const renderComponent = () =>
render(
<Router>
<Propose />
</Router>
);
describe('Propose', () => {
it('should render successfully', () => {
const { baseElement } = renderComponent();
expect(baseElement).toBeTruthy();
});
it('should render the heading, proposal type question and options', () => {
renderComponent();
expect(
screen.getByText('What type of proposal would you like to make?')
).toBeTruthy();
expect(screen.getByText('Network parameter')).toBeTruthy();
expect(screen.getByText('New market')).toBeTruthy();
expect(screen.getByText('Update market')).toBeTruthy();
expect(screen.getByText('New asset')).toBeTruthy();
expect(screen.getByText('Freeform')).toBeTruthy();
expect(screen.getByText('Let me choose (raw proposal)')).toBeTruthy();
});
});

View File

@ -1,21 +1,86 @@
import Routes from '../../routes';
import { useTranslation } from 'react-i18next';
import { ProposalForm } from '@vegaprotocol/governance';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { Heading } from '../../../components/heading';
import { VegaWalletContainer } from '../../../components/vega-wallet-container';
export const Propose = () => {
const { VEGA_DOCS_URL, VEGA_EXPLORER_URL } = useEnvironment();
const { t } = useTranslation();
return (
<>
<Heading title={t('NewProposal')} />
<VegaWalletContainer>
{() => (
<>
<p>{t('MinProposalRequirements')}</p>
<ProposalForm />
</>
)}
</VegaWalletContainer>
<section className="pb-6">
<Heading title={t('NewProposal')} />
<div className="text-sm">
{VEGA_DOCS_URL && (
<p>
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p>
{t('MoreProposalsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/governance`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/governance`}</Link>
</p>
)}
</div>
</section>
<section>
<h2 className="text-h5">{t('ProposalTypeQuestion')}</h2>
<ul>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/network-parameter`}>
{t('NetworkParameter')}
</Link>
</p>
</li>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/new-market`}>
{t('NewMarket')}
</Link>
</p>
</li>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/update-market`}>
{t('UpdateMarket')}
</Link>
</p>
</li>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/new-asset`}>
{t('NewAsset')}
</Link>
</p>
</li>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/freeform`}>
{t('Freeform')}
</Link>
</p>
</li>
<li>
<p>
<Link href={`${Routes.GOVERNANCE}/propose/raw`}>
{t('RawProposal')}
</Link>
</p>
</li>
</ul>
</section>
</>
);
};

View File

@ -0,0 +1 @@
export { ProposeRaw, ProposeRaw as default } from './propose-raw';

View File

@ -1,5 +1,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { addHours, getTime } from 'date-fns';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { MockedProvider } from '@apollo/client/testing';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
@ -8,11 +10,11 @@ import {
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';
import { ProposeRaw } from './propose-raw';
import { PROPOSAL_EVENT_SUB } from '@vegaprotocol/governance';
import type { ProposalEvent } from '@vegaprotocol/governance';
describe('ProposalForm', () => {
describe('Raw proposal form', () => {
const pubkey = '0x123';
const mockProposalEvent: MockedResponse<ProposalEvent> = {
request: {
@ -44,18 +46,20 @@ describe('ProposalForm', () => {
};
const setup = (mockSendTx = jest.fn()) => {
return render(
<MockedProvider mocks={[mockProposalEvent]}>
<VegaWalletContext.Provider
value={
{
keypair: { pub: pubkey },
sendTx: mockSendTx,
} as unknown as VegaWalletContextShape
}
>
<ProposalForm />
</VegaWalletContext.Provider>
</MockedProvider>
<AppStateProvider>
<MockedProvider mocks={[mockProposalEvent]}>
<VegaWalletContext.Provider
value={
{
keypair: { pub: pubkey },
sendTx: mockSendTx,
} as unknown as VegaWalletContextShape
}
>
<ProposeRaw />
</VegaWalletContext.Provider>
</MockedProvider>
</AppStateProvider>
);
};
@ -125,8 +129,8 @@ describe('ProposalForm', () => {
value: '300',
},
},
closingTimestamp: 1657721401,
enactmentTimestamp: 1657807801,
closingTimestamp: Math.floor(getTime(addHours(new Date(), 2)) / 1000),
enactmentTimestamp: Math.floor(getTime(addHours(new Date(), 3)) / 1000),
},
});
fireEvent.change(screen.getByTestId('proposal-data'), {

View File

@ -0,0 +1,107 @@
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useEnvironment } from '@vegaprotocol/environment';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
import {
FormGroup,
InputError,
Link,
TextArea,
} from '@vegaprotocol/ui-toolkit';
import { useProposalSubmit } from '@vegaprotocol/governance';
import {
ProposalFormSubmit,
ProposalFormTransactionDialog,
} from '../../components/propose';
export interface RawProposalFormFields {
rawProposalData: string;
}
export const ProposeRaw = () => {
const { VEGA_EXPLORER_URL, VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<RawProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const hasError = Boolean(errors.rawProposalData?.message);
const onSubmit = async (fields: RawProposalFormFields) => {
await submit(JSON.parse(fields.rawProposalData));
};
return (
<>
<Heading title={t('NewRawProposal')} />
<VegaWalletContainer>
{() => (
<>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreProposalsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/governance`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/governance`}</Link>
</p>
)}
<div data-testid="raw-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<FormGroup
label="Make a proposal by submitting JSON"
labelFor="proposal-data"
>
<TextArea
id="proposal-data"
className="min-h-[200px]"
hasError={hasError}
data-testid="proposal-data"
{...register('rawProposalData', {
required: t('Required'),
validate: {
validateJson: (value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return t('Must be valid JSON');
}
},
},
})}
/>
{errors.rawProposalData?.message && (
<InputError intent="danger">
{errors.rawProposalData?.message}
</InputError>
)}
</FormGroup>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</>
);
};

View File

@ -0,0 +1,57 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: ProposalMarketsQuery
// ====================================================
export interface ProposalMarketsQuery_marketsConnection_edges_node_tradableInstrument_instrument {
__typename: "Instrument";
/**
* 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) (string)
*/
code: string;
}
export interface ProposalMarketsQuery_marketsConnection_edges_node_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of, or reference to, a fully specified instrument.
*/
instrument: ProposalMarketsQuery_marketsConnection_edges_node_tradableInstrument_instrument;
}
export interface ProposalMarketsQuery_marketsConnection_edges_node {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* An instance of, or reference to, a tradable instrument.
*/
tradableInstrument: ProposalMarketsQuery_marketsConnection_edges_node_tradableInstrument;
}
export interface ProposalMarketsQuery_marketsConnection_edges {
__typename: "MarketEdge";
node: ProposalMarketsQuery_marketsConnection_edges_node;
}
export interface ProposalMarketsQuery_marketsConnection {
__typename: "MarketConnection";
/**
* The markets in this connection
*/
edges: ProposalMarketsQuery_marketsConnection_edges[];
}
export interface ProposalMarketsQuery {
marketsConnection: ProposalMarketsQuery_marketsConnection;
}

View File

@ -0,0 +1,4 @@
export {
ProposeUpdateMarket,
ProposeUpdateMarket as default,
} from './propose-update-market';

View File

@ -0,0 +1,220 @@
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { mockWalletContext } from '../../test-helpers/mocks';
import { ProposeUpdateMarket } from './propose-update-market';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
import { MARKETS_QUERY } from './propose-update-market';
import type { ProposalMarketsQuery } from './__generated__/ProposalMarketsQuery';
import { gql } from '@apollo/client';
const updateMarketNetworkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: gql`
query NetworkParams {
networkParameters {
key
value
}
}
`,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.maxClose',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.maxEnact',
value: '8760h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.minClose',
value: '1h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.minEnact',
value: '2h0m0s',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.minProposerBalance',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1000000000000000000',
},
],
},
},
};
const marketQueryMock: MockedResponse<ProposalMarketsQuery> = {
request: {
query: MARKETS_QUERY,
},
result: {
data: {
marketsConnection: {
__typename: 'MarketConnection',
edges: [
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: 'd2319c91104a523032cf04ac4e20962b87ee1f187d1e411a2cac12554dd38f29',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'ETHUSD (September Market)',
code: 'ETHiUSDT',
},
},
},
},
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: 'bb941f84e25b3a06068e33917d01215d56a51e9abff6ff9b9a3f2cf49b495e37',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Las Vegas nuggets',
code: 'Nuggets2',
},
},
},
},
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: '976f71b8d6f99f3685297156a11245f21e49c99d3be17f2577a4bf693b5f37d1',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Nuggets',
code: 'Nuggets',
},
},
},
},
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: '1cb2e1755208914b6f258a28babd19ae8dfbaf4084d8867d8a120c50dca1e17f',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'CELUSD (June 2022)',
code: 'CELUSD',
},
},
},
},
],
},
},
},
};
const renderComponent = () =>
render(
<MockedProvider
mocks={[updateMarketNetworkParamsQueryMock, marketQueryMock]}
addTypename={false}
>
<Router>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposeUpdateMarket />
</VegaWalletContext.Provider>
</AppStateProvider>
</Router>
</MockedProvider>
);
// Note: form submission is tested in propose-raw.spec.tsx. Reusable form
// components are tested in their own directory.
describe('Propose Update Market', () => {
it('should render successfully', async () => {
const { baseElement } = renderComponent();
await expect(baseElement).toBeTruthy();
});
it('should render the title', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('Update market proposal')).toBeInTheDocument()
);
});
it('should render the form components', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByTestId('update-market-proposal-form')).toBeTruthy()
);
expect(screen.getByTestId('min-proposal-requirements')).toBeTruthy();
expect(screen.getByTestId('proposal-title')).toBeTruthy();
expect(screen.getByTestId('proposal-description')).toBeTruthy();
expect(screen.getByTestId('proposal-terms')).toBeTruthy();
expect(screen.getByTestId('proposal-vote-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-enactment-deadline')).toBeTruthy();
expect(screen.getByTestId('proposal-submit')).toBeTruthy();
expect(screen.getByTestId('proposal-transaction-dialog')).toBeTruthy();
});
it('should render the select element with no initial value', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('Update market proposal')).toBeInTheDocument()
);
expect(screen.getByTestId('proposal-market-select')).toHaveValue('');
});
it('should render the correct market details when the market select is used', async () => {
renderComponent();
await waitFor(() =>
expect(screen.getByText('Update market proposal')).toBeInTheDocument()
);
fireEvent.change(screen.getByTestId('proposal-market-select'), {
target: {
value:
'd2319c91104a523032cf04ac4e20962b87ee1f187d1e411a2cac12554dd38f29',
},
});
expect(screen.getByTestId('proposal-market-select')).toHaveValue(
'd2319c91104a523032cf04ac4e20962b87ee1f187d1e411a2cac12554dd38f29'
);
expect(screen.getByTestId('update-market-details')).toHaveTextContent(
'ETHUSD (September Market)'
);
expect(screen.getByTestId('update-market-details')).toHaveTextContent(
'ETHiUSDT'
);
expect(screen.getByTestId('update-market-details')).toHaveTextContent(
'd2319c91104a523032cf04ac4e20962b87ee1f187d1e411a2cac12554dd38f29'
);
});
});

View File

@ -0,0 +1,311 @@
import { gql, useQuery } from '@apollo/client';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import {
useProposalSubmit,
getClosingTimestamp,
getEnactmentTimestamp,
} from '@vegaprotocol/governance';
import { useEnvironment } from '@vegaprotocol/environment';
import {
ProposalFormSubheader,
ProposalFormMinRequirements,
ProposalFormTitle,
ProposalFormDescription,
ProposalFormTerms,
ProposalFormSubmit,
ProposalFormTransactionDialog,
ProposalFormVoteAndEnactmentDeadline,
} from '../../components/propose';
import {
AsyncRenderer,
FormGroup,
InputError,
KeyValueTable,
KeyValueTableRow,
Link,
Select,
} from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { VegaWalletContainer } from '../../../../components/vega-wallet-container';
import type { ProposalMarketsQuery } from './__generated__/ProposalMarketsQuery';
import { NetworkParams, useNetworkParams } from '@vegaprotocol/react-helpers';
export const MARKETS_QUERY = gql`
query ProposalMarketsQuery {
marketsConnection {
edges {
node {
id
tradableInstrument {
instrument {
name
code
}
}
}
}
}
}
`;
export interface UpdateMarketProposalFormFields {
proposalVoteDeadline: string;
proposalEnactmentDeadline: string;
proposalTitle: string;
proposalDescription: string;
proposalMarketId: string;
proposalTerms: string;
proposalReference: string;
}
const docsLink = '/update-market-proposal';
export const ProposeUpdateMarket = () => {
const {
params,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_updateMarket_maxClose,
NetworkParams.governance_proposal_updateMarket_minClose,
NetworkParams.governance_proposal_updateMarket_maxEnact,
NetworkParams.governance_proposal_updateMarket_minEnact,
NetworkParams.governance_proposal_updateMarket_minProposerBalance,
NetworkParams.spam_protection_proposal_min_tokens,
]);
const {
data: marketsData,
loading: marketsLoading,
error: marketsError,
} = useQuery<ProposalMarketsQuery>(MARKETS_QUERY);
const sortedMarkets = useMemo(() => {
if (!marketsData) {
return [];
}
return marketsData.marketsConnection.edges
.map((edge) => edge.node)
.sort((a, b) => {
const aName = a.tradableInstrument.instrument.name;
const bName = b.tradableInstrument.instrument.name;
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
}, [marketsData]);
const [selectedMarket, setSelectedMarket] = useState<string | undefined>(
undefined
);
const { VEGA_EXPLORER_URL, VEGA_DOCS_URL } = useEnvironment();
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<UpdateMarketProposalFormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const onSubmit = async (fields: UpdateMarketProposalFormFields) => {
await submit({
rationale: {
title: fields.proposalTitle,
description: fields.proposalDescription,
},
terms: {
updateMarket: {
marketId: fields.proposalMarketId,
changes: {
...JSON.parse(fields.proposalTerms),
},
},
closingTimestamp: getClosingTimestamp(fields.proposalVoteDeadline),
enactmentTimestamp: getEnactmentTimestamp(
fields.proposalVoteDeadline,
fields.proposalEnactmentDeadline
),
},
});
};
return (
<AsyncRenderer
loading={networkParamsLoading && marketsLoading}
error={networkParamsError && marketsError}
data={params && marketsData}
>
<Heading title={t('UpdateMarketProposal')} />
<VegaWalletContainer>
{() => (
<>
<ProposalFormMinRequirements
minProposerBalance={
params.governance_proposal_updateMarket_minProposerBalance
}
spamProtectionMin={params.spam_protection_proposal_min_tokens}
/>
{VEGA_DOCS_URL && (
<p className="text-sm" data-testid="proposal-docs-link">
<span className="mr-1">{t('ProposalTermsText')}</span>
<Link
href={`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}
target="_blank"
>{`${VEGA_DOCS_URL}/tutorials/proposals${docsLink}`}</Link>
</p>
)}
{VEGA_EXPLORER_URL && (
<p className="text-sm">
{t('MoreMarketsInfo')}{' '}
<Link
href={`${VEGA_EXPLORER_URL}/markets`}
target="_blank"
>{`${VEGA_EXPLORER_URL}/markets`}</Link>
</p>
)}
<div data-testid="update-market-proposal-form">
<form onSubmit={handleSubmit(onSubmit)}>
<ProposalFormSubheader>
{t('ProposalRationale')}
</ProposalFormSubheader>
<ProposalFormTitle
registerField={register('proposalTitle', {
required: t('Required'),
})}
errorMessage={errors?.proposalTitle?.message}
/>
<ProposalFormDescription
registerField={register('proposalDescription', {
required: t('Required'),
})}
errorMessage={errors?.proposalDescription?.message}
/>
<ProposalFormSubheader>
{t('SelectAMarketToChange')}
</ProposalFormSubheader>
<FormGroup
label={t('SelectAMarketToChange')}
labelFor="proposal-market"
hideLabel={true}
>
<Select
data-testid="proposal-market-select"
id="proposal-market"
{...register('proposalMarketId', {
required: t('Required'),
})}
onChange={(e) => setSelectedMarket(e.target.value)}
>
<option value="">{t('SelectMarket')}</option>
{sortedMarkets.map((market) => (
<option value={market.id} key={market.id}>
{market.tradableInstrument.instrument.name}
</option>
))}
</Select>
{errors?.proposalMarketId?.message && (
<InputError intent="danger">
{errors?.proposalMarketId?.message}
</InputError>
)}
</FormGroup>
{selectedMarket && (
<div className="mt-[-20px] mb-6">
<KeyValueTable data-testid="update-market-details">
<KeyValueTableRow>
{t('MarketName')}
{
marketsData?.marketsConnection?.edges?.find(
({ node: market }) => market.id === selectedMarket
)?.node.tradableInstrument.instrument.name
}
</KeyValueTableRow>
<KeyValueTableRow>
{t('MarketCode')}
{
marketsData?.marketsConnection?.edges?.find(
({ node: market }) => market.id === selectedMarket
)?.node.tradableInstrument.instrument.code
}
</KeyValueTableRow>
<KeyValueTableRow>
{t('MarketId')}
{selectedMarket}
</KeyValueTableRow>
</KeyValueTable>
</div>
)}
<ProposalFormTerms
registerField={register('proposalTerms', {
required: t('Required'),
validate: {
validateJson: (value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return t('Must be valid JSON');
}
},
},
})}
labelOverride={t('ProposeUpdateMarketTerms')}
errorMessage={errors?.proposalTerms?.message}
customDocLink={docsLink}
/>
<ProposalFormVoteAndEnactmentDeadline
voteRegister={register('proposalVoteDeadline', {
required: t('Required'),
})}
voteErrorMessage={errors?.proposalVoteDeadline?.message}
voteMinClose={
params.governance_proposal_updateMarket_minClose
}
voteMaxClose={
params.governance_proposal_updateMarket_maxClose
}
enactmentRegister={register('proposalEnactmentDeadline', {
required: t('Required'),
})}
enactmentErrorMessage={
errors?.proposalEnactmentDeadline?.message
}
enactmentMinClose={
params.governance_proposal_updateMarket_minEnact
}
enactmentMaxClose={
params.governance_proposal_updateMarket_maxEnact
}
/>
<ProposalFormSubmit isSubmitting={isSubmitting} />
<ProposalFormTransactionDialog
finalizedProposal={finalizedProposal}
TransactionDialog={Dialog}
/>
</form>
</div>
</>
)}
</VegaWalletContainer>
</AsyncRenderer>
);
};

View File

@ -3,7 +3,7 @@ import { Splash } from '@vegaprotocol/ui-toolkit';
import React from 'react';
import type { WithTranslation } from 'react-i18next';
import { withTranslation } from 'react-i18next';
import { Route, Routes } from 'react-router-dom';
import { useRoutes } from 'react-router-dom';
import { SplashLoader } from '../components/splash-loader';
import routerConfig from './router-config';
@ -45,6 +45,8 @@ class RouteErrorBoundary extends React.Component<
const BoundaryWithTranslation = withTranslation()(RouteErrorBoundary);
export const AppRouter = () => {
const routes = useRoutes(routerConfig);
const splashLoading = (
<Splash>
<SplashLoader />
@ -52,26 +54,8 @@ export const AppRouter = () => {
);
return (
// @ts-ignore withTranslation HOC types not working
<BoundaryWithTranslation>
<React.Suspense fallback={splashLoading}>
<Routes>
{routerConfig.map(
({ path, component: Component, name, children }) => (
<Route key={name} path={path} element={<Component name={name} />}>
{children && children.length
? children.map((child) => (
<Route
key={`${name}-${child.path ? child.path : 'index'}`}
{...child}
/>
))
: null}
</Route>
)
)}
</Routes>
</React.Suspense>
<React.Suspense fallback={splashLoading}>{routes}</React.Suspense>
</BoundaryWithTranslation>
);
};

View File

@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { Heading } from '../../../components/heading';
import { SplashLoader } from '../../../components/splash-loader';
import { NetworkParams } from '../../../config';
import {
AppStateActionType,
useAppState,
@ -18,7 +17,7 @@ import {
import type { Rewards } from './__generated__/Rewards';
import { RewardInfo } from './reward-info';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useNetworkParams } from '@vegaprotocol/react-helpers';
import { useNetworkParams, NetworkParams } from '@vegaprotocol/react-helpers';
export const REWARDS_QUERY = gql`
query Rewards($partyId: ID!) {
@ -73,33 +72,30 @@ export const RewardsIndex = () => {
variables: { partyId: keypair?.pub },
skip: !keypair?.pub,
});
const {
data: rewardAssetData,
loading: rewardAssetLoading,
error: rewardAssetError,
} = useNetworkParams([
NetworkParams.REWARD_ASSET,
NetworkParams.REWARD_PAYOUT_DURATION,
const { params } = useNetworkParams([
NetworkParams.reward_asset,
NetworkParams.reward_staking_delegation_payoutDelay,
]);
const payoutDuration = React.useMemo(() => {
if (!rewardAssetData || !rewardAssetData[1]) {
if (!params) {
return 0;
}
return new Duration(rewardAssetData[1]).milliseconds();
}, [rewardAssetData]);
return new Duration(
params.reward_staking_delegation_payoutDelay
).milliseconds();
}, [params]);
if (error || rewardAssetError) {
if (error) {
return (
<section>
<p>{t('Something went wrong')}</p>
{error && <pre>{error.message}</pre>}
{rewardAssetError && <pre>{rewardAssetError.message}</pre>}
</section>
);
}
if (loading || rewardAssetLoading || !rewardAssetData?.length) {
if (loading || !params) {
return (
<Splash>
<SplashLoader />
@ -145,7 +141,7 @@ export const RewardsIndex = () => {
<RewardInfo
currVegaKey={keypair}
data={data}
rewardAssetId={rewardAssetData[0]}
rewardAssetId={params.reward_asset}
/>
) : (
<div>

View File

@ -1,4 +1,5 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Home from './home';
import NotFound from './not-found';
import NotPermitted from './not-permitted';
@ -129,6 +130,48 @@ const LazyGovernancePropose = React.lazy(
)
);
const LazyGovernanceProposeNetworkParameter = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-network-parameter", webpackPrefetch: true */ './governance/propose/network-parameter'
)
);
const LazyGovernanceProposeNewMarket = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-new-market", webpackPrefetch: true */ './governance/propose/new-market'
)
);
const LazyGovernanceProposeUpdateMarket = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-update-market", webpackPrefetch: true */ './governance/propose/update-market'
)
);
const LazyGovernanceProposeNewAsset = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-new-asset", webpackPrefetch: true */ './governance/propose/new-asset'
)
);
const LazyGovernanceProposeFreeform = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-freeform", webpackPrefetch: true */ './governance/propose/freeform'
)
);
const LazyGovernanceProposeRaw = React.lazy(
() =>
import(
/* webpackChunkName: "route-governance-propose-raw", webpackPrefetch: true */ './governance/propose/raw'
)
);
const LazyRewards = React.lazy(
() =>
import(
@ -153,14 +196,12 @@ const LazyWithdrawals = React.lazy(
const routerConfig = [
{
path: Routes.HOME,
name: 'Home',
// Not lazy as loaded when a user first hits the site
component: Home,
element: <Home name="Home" />,
},
{
path: Routes.TRANCHES,
name: 'Tranches',
component: LazyTranches,
element: <LazyTranches name="Tranches" />,
children: [
{ index: true, element: <LazyTranchesTranches /> },
{ path: ':trancheId', element: <LazyTranchesTranche /> },
@ -168,13 +209,11 @@ const routerConfig = [
},
{
path: Routes.CLAIM,
name: 'Claim',
component: LazyClaim,
element: <LazyClaim name="Claim" />,
},
{
path: Routes.STAKING,
name: 'Staking',
component: LazyStaking,
element: <LazyStaking name="Staking" />,
children: [
{ path: 'associate', element: <LazyStakingAssociate /> },
{ path: 'disassociate', element: <LazyStakingDisassociate /> },
@ -191,18 +230,15 @@ const routerConfig = [
},
{
path: Routes.REWARDS,
name: 'Rewards',
component: LazyRewards,
element: <LazyRewards name="Rewards" />,
},
{
path: Routes.WITHDRAWALS,
name: 'Withdrawals',
component: LazyWithdrawals,
element: <LazyWithdrawals name="Withdrawals" />,
},
{
path: Routes.VESTING,
name: 'Vesting',
component: LazyRedemption,
element: <LazyRedemption name="Vesting" />,
children: [
{
index: true,
@ -216,31 +252,48 @@ const routerConfig = [
},
{
path: Routes.GOVERNANCE,
name: 'Governance',
component: LazyGovernance,
element: <LazyGovernance name="Governance" />,
children: [
{ path: ':proposalId', element: <LazyGovernanceProposal /> },
{ path: 'propose', element: <LazyGovernancePropose /> },
{ path: 'rejected', element: <LazyRejectedGovernanceProposals /> },
{ index: true, element: <LazyGovernanceProposals /> },
{
path: 'propose',
element: <Outlet />,
children: [
{ index: true, element: <LazyGovernancePropose /> },
{
path: 'network-parameter',
element: <LazyGovernanceProposeNetworkParameter />,
},
{
path: 'new-market',
element: <LazyGovernanceProposeNewMarket />,
},
{
path: 'update-market',
element: <LazyGovernanceProposeUpdateMarket />,
},
{ path: 'new-asset', element: <LazyGovernanceProposeNewAsset /> },
{ path: 'freeform', element: <LazyGovernanceProposeFreeform /> },
{ path: 'raw', element: <LazyGovernanceProposeRaw /> },
],
},
{ path: ':proposalId', element: <LazyGovernanceProposal /> },
{ path: 'rejected', element: <LazyRejectedGovernanceProposals /> },
],
},
{
path: Routes.NOT_PERMITTED,
name: 'Not permitted',
// Not lazy as loaded when a user first hits the site
component: NotPermitted,
element: <NotPermitted name="Not permitted" />,
},
{
path: Routes.CONTRACTS,
name: 'Contracts',
component: LazyContracts,
element: <LazyContracts name="Contracts" />,
},
{
path: '*',
name: 'NotFound',
// Not lazy as loaded when a user first hits the site
component: NotFound,
element: <NotFound name="NotFound" />,
},
];

View File

@ -6,6 +6,7 @@ import { MemoryRouter } from 'react-router-dom';
import { addDecimal } from '@vegaprotocol/react-helpers';
import type { Nodes_nodes } from './__generated__/Nodes';
import type { PartialDeep } from 'type-fest';
import { ValidatorStatus } from '@vegaprotocol/types';
jest.mock('../../components/epoch-countdown', () => ({
EpochCountdown: () => <div data-testid="epoch-info"></div>,
@ -55,7 +56,7 @@ const MOCK_NODES = {
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: 'tendermint',
status: ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
@ -72,7 +73,7 @@ const MOCK_NODES = {
stakeScore: '0.0966762995515676',
performanceScore: '0.999629748500531',
votingPower: '1163',
status: 'tendermint',
status: ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),
@ -161,7 +162,7 @@ describe('Nodes list', () => {
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: 'tendermint',
status: ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT,
__typename: 'RankingScore',
},
}),

View File

@ -1,5 +1,5 @@
import { gql, useQuery } from '@apollo/client';
import { useEffect, useMemo, useRef, forwardRef } from 'react';
import { forwardRef, useEffect, useMemo, useRef } from 'react';
import {
AgGridDynamic as AgGrid,
AsyncRenderer,
@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { EpochCountdown } from '../../components/epoch-countdown';
import { BigNumber } from '../../lib/bignumber';
import { formatNumber } from '@vegaprotocol/react-helpers';
import { ValidatorStatus } from '@vegaprotocol/types';
import type { Nodes } from './__generated__/Nodes';
import type { Staking_epoch } from './__generated__/Staking';
import type { ColDef } from 'ag-grid-community';
@ -136,7 +137,16 @@ export const NodeList = ({ epoch }: NodeListProps) => {
? '-'
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
'%';
const statusTranslated = t(`status-${status}`);
const statusTranslated = t(
`${
(status === ValidatorStatus.VALIDATOR_NODE_STATUS_ERSATZ &&
'Ersatz') ||
(status === ValidatorStatus.VALIDATOR_NODE_STATUS_PENDING &&
'Pending') ||
(status === ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT &&
'Consensus')
}`
);
return {
id,

View File

@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { TokenInput } from '../../components/token-input';
import { NetworkParams } from '../../config';
import { useAppState } from '../../contexts/app-state/app-state-context';
import { useSearchParams } from '../../hooks/use-search-params';
import { BigNumber } from '../../lib/bignumber';
@ -30,7 +29,7 @@ import type {
UndelegateSubmissionBody,
} from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useNetworkParam } from '@vegaprotocol/react-helpers';
import { useNetworkParam, NetworkParams } from '@vegaprotocol/react-helpers';
export const PARTY_DELEGATIONS_QUERY = gql`
query PartyDelegations($partyId: ID!) {
@ -103,14 +102,14 @@ export const StakingForm = ({
setAmount('');
}, [action, setAmount]);
const { data } = useNetworkParam(
NetworkParams.VALIDATOR_DELEGATION_MIN_AMOUNT
const { param: minAmount } = useNetworkParam(
NetworkParams.validators_delegation_minAmount
);
const minTokensWithDecimals = React.useMemo(() => {
const minTokens = new BigNumber(data && data.length === 1 ? data[0] : '');
const minTokens = new BigNumber(minAmount !== null ? minAmount : '');
return addDecimal(minTokens, appState.decimals);
}, [appState.decimals, data]);
}, [appState.decimals, minAmount]);
const maxDelegation = React.useMemo(() => {
if (action === Actions.Add) {

View File

@ -10,8 +10,11 @@ import {
WithdrawalsTable,
} from '@vegaprotocol/withdraws';
import { useState } from 'react';
import { useDocumentTitle } from '../../hooks/use-document-title';
import type { RouteChildProps } from '../index';
const Withdrawals = () => {
const Withdrawals = ({ name }: RouteChildProps) => {
useDocumentTitle(name);
const { t } = useTranslation();
return (

View File

@ -72,6 +72,8 @@ const getBundledEnvironmentValue = (key: EnvKey) => {
return process.env['NX_VEGA_WALLET_URL'];
case 'VEGA_TOKEN_URL':
return process.env['NX_VEGA_TOKEN_URL'];
case 'VEGA_DOCS_URL':
return process.env['NX_VEGA_DOCS_URL'];
}
};

View File

@ -22,6 +22,7 @@ const schemaObject = {
VEGA_ENV: z.nativeEnum(Networks),
VEGA_EXPLORER_URL: z.optional(z.string()),
VEGA_TOKEN_URL: z.optional(z.string()),
VEGA_DOCS_URL: z.optional(z.string()),
VEGA_NETWORKS: z
.object(
Object.keys(Networks).reduce(

View File

@ -1,3 +1,2 @@
export * from './proposals-hooks';
export * from './proposal-form';
export * from './proposals-queries';

View File

@ -1,84 +0,0 @@
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<FormFields>();
const { finalizedProposal, submit, Dialog } = useProposalSubmit();
const hasError = Boolean(errors.proposalData?.message);
const onSubmit = async (fields: FormFields) => {
await submit(JSON.parse(fields.proposalData));
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormGroup
label="Make a proposal by submitting JSON"
labelFor="proposal-data"
>
<TextArea
id="proposal-data"
className="min-h-[200px]"
hasError={hasError}
data-testid="proposal-data"
{...register('proposalData', {
required: t('Required'),
validate: {
validateJson: (value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return t('Must be valid JSON');
}
},
},
})}
/>
{errors.proposalData?.message && (
<InputError intent="danger">
{errors.proposalData?.message}
</InputError>
)}
</FormGroup>
<Button
variant="primary"
type="submit"
data-testid="proposal-submit"
disabled={isSubmitting}
>
{isSubmitting ? t('Submitting') : t('Submit')} {t('Proposal')}
</Button>
<Dialog
title={getProposalDialogTitle(finalizedProposal?.state)}
intent={getProposalDialogIntent(finalizedProposal?.state)}
icon={getProposalDialogIcon(finalizedProposal?.state)}
>
{finalizedProposal?.rejectionReason ? (
<p>{finalizedProposal.rejectionReason}</p>
) : undefined}
</Dialog>
</form>
);
};

View File

@ -0,0 +1,33 @@
import { getClosingTimestamp } from './get-closing-timestamp';
import { addHours, addMinutes, getTime } from 'date-fns';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterEach(() => {
jest.useRealTimers();
});
describe('getClosingTimestamp', () => {
it('should return the correct timestamp if the proposalVoteDeadline is 1 (when 2 mins are added)', () => {
const proposalVoteDeadline = '1';
const expected = Math.floor(
getTime(
addHours(addMinutes(new Date(), 2), Number(proposalVoteDeadline))
) / 1000
);
const actual = getClosingTimestamp(proposalVoteDeadline);
expect(actual).toEqual(expected);
});
it('should return the correct timestamp if the proposalVoteDeadline is 2 (when no extra mins are added)', () => {
const proposalVoteDeadline = '2';
const expected = Math.floor(
getTime(addHours(new Date(), Number(proposalVoteDeadline))) / 1000
);
const actual = getClosingTimestamp(proposalVoteDeadline);
expect(actual).toEqual(expected);
});
});

View File

@ -0,0 +1,14 @@
import { addHours, addMinutes, getTime } from 'date-fns';
// If proposaVoteDeadline is at its minimum of 1 hour, then we add
// 2 extra minutes to the closing timestamp to ensure that there's time
// to confirm in the wallet.
export const getClosingTimestamp = (proposalVoteDeadline: string) =>
Math.floor(
getTime(
proposalVoteDeadline === '1'
? addHours(addMinutes(new Date(), 2), Number(proposalVoteDeadline))
: addHours(new Date(), Number(proposalVoteDeadline))
) / 1000
);

View File

@ -0,0 +1,31 @@
import { getEnactmentTimestamp } from './get-enactment-timestamp';
import { addHours, getTime } from 'date-fns';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterEach(() => {
jest.useRealTimers();
});
describe('getEnactmentTimestamp', () => {
it('should return the correct timestamp', () => {
const proposalVoteDeadline = '2';
const enactmentDeadline = '1';
const expected = Math.floor(
getTime(
addHours(
new Date(),
Number(proposalVoteDeadline) + Number(enactmentDeadline)
)
) / 1000
);
const actual = getEnactmentTimestamp(
proposalVoteDeadline,
enactmentDeadline
);
expect(actual).toEqual(expected);
});
});

View File

@ -0,0 +1,15 @@
import { addHours, fromUnixTime, getTime } from 'date-fns';
import { getClosingTimestamp } from './get-closing-timestamp';
export const getEnactmentTimestamp = (
proposalVoteDeadline: string,
enactmentDeadline: string
) =>
Math.floor(
getTime(
addHours(
new Date(fromUnixTime(getClosingTimestamp(proposalVoteDeadline))),
Number(enactmentDeadline)
)
) / 1000
);

View File

@ -0,0 +1,33 @@
import { addHours, addMinutes, getTime } from 'date-fns';
import { getValidationTimestamp } from './get-validation-timestamp';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterEach(() => {
jest.useRealTimers();
});
describe('getValidationTimestamp', () => {
it('should return the correct timestamp if the proposalValidationDeadline is 0 (when 2 mins are added)', () => {
const proposalValidationDeadline = '0';
const expected = Math.floor(
getTime(
addHours(addMinutes(new Date(), 2), Number(proposalValidationDeadline))
) / 1000
);
const actual = getValidationTimestamp(proposalValidationDeadline);
expect(actual).toEqual(expected);
});
it('should return the correct timestamp if the proposalValidationDeadline is 1 (when no extra mins are added)', () => {
const proposalValidationDeadline = '1';
const expected = Math.floor(
getTime(addHours(new Date(), Number(proposalValidationDeadline))) / 1000
);
const actual = getValidationTimestamp(proposalValidationDeadline);
expect(actual).toEqual(expected);
});
});

View File

@ -0,0 +1,17 @@
import { addHours, addMinutes, getTime } from 'date-fns';
// If proposalValidationDeadline is at its minimum of 0 hours, then we add
// 2 extra minutes to the validation timestamp to ensure that there's time
// to confirm in the wallet.
export const getValidationTimestamp = (proposalValidationDeadline: string) =>
Math.floor(
getTime(
proposalValidationDeadline === '0'
? addHours(
addMinutes(new Date(), 2),
Number(proposalValidationDeadline)
)
: addHours(new Date(), Number(proposalValidationDeadline))
) / 1000
);

View File

@ -1 +1,4 @@
export * from './proposal-dialog-helpers';
export * from './get-closing-timestamp';
export * from './get-enactment-timestamp';
export * from './get-validation-timestamp';

View File

@ -1,15 +1,13 @@
import { gql, useQuery } from '@apollo/client';
import type { LiquidityProvisionStatus } from '@vegaprotocol/types';
import { AccountType } from '@vegaprotocol/types';
import { useNetworkParam } from '@vegaprotocol/react-helpers';
import { useNetworkParam, NetworkParams } from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import type {
MarketLiquidity,
MarketLiquidity_market_data_liquidityProviderFeeShare,
} from './__generated__';
const SISKA_NETWORK_PARAMETER = 'market.liquidity.stakeToCcySiskas';
const MARKET_LIQUIDITY_QUERY = gql`
query MarketLiquidity($marketId: ID!) {
market(id: $marketId) {
@ -106,7 +104,9 @@ export const useLiquidityProvision = ({
partyId?: string;
marketId?: string;
}) => {
const { data: stakeToCcySiskas } = useNetworkParam(SISKA_NETWORK_PARAMETER);
const { param: stakeToCcySiskas } = useNetworkParam(
NetworkParams.market_liquidity_stakeToCcySiskas
);
const stakeToCcySiska = stakeToCcySiskas && stakeToCcySiskas[0];
const { data, loading, error } = useQuery<MarketLiquidity>(
MARKET_LIQUIDITY_QUERY,

View File

@ -0,0 +1,115 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { NetworkParamsKey } from './use-network-params';
import {
NetworkParams,
NETWORK_PARAMETERS_QUERY,
NETWORK_PARAMETER_QUERY,
useNetworkParam,
useNetworkParams,
} from './use-network-params';
import type { ReactNode } from 'react';
import type { NetworkParams as NetworkParamsResponse } from './__generated__';
describe('useNetworkParam', () => {
const setup = (arg: NetworkParamsKey) => {
const mock: MockedResponse = {
request: {
query: NETWORK_PARAMETER_QUERY,
variables: {
key: arg,
},
},
result: {
data: {
networkParameter: {
__typename: 'NetworkParameter',
key: 'reward.staking.delegation.payoutDelay',
value: '200',
},
},
},
};
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
);
return renderHook(() => useNetworkParam(arg), { wrapper });
};
it('returns a single param value', async () => {
const { result } = setup(
NetworkParams.reward_staking_delegation_payoutDelay
);
expect(result.current.param).toBe(null);
await waitFor(() => {
expect(result.current.param).toEqual('200');
});
});
});
describe('useNetworkParams', () => {
const setup = (args?: NetworkParamsKey[]) => {
const mock: MockedResponse<NetworkParamsResponse> = {
request: {
query: NETWORK_PARAMETERS_QUERY,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'spam.protection.proposal.min.tokens',
value: '1',
},
{
__typename: 'NetworkParameter',
key: 'governance.proposal.updateMarket.minProposerBalance',
value: '2',
},
{
__typename: 'NetworkParameter',
key: 'reward.staking.delegation.payoutDelay',
value: '200',
},
],
},
},
};
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
);
return renderHook(() => useNetworkParams(args), { wrapper });
};
it('returns an object with only desired params', async () => {
const { result } = setup([
NetworkParams.spam_protection_proposal_min_tokens,
NetworkParams.governance_proposal_updateMarket_minProposerBalance,
]);
expect(result.current.params).toBe(null);
await waitFor(() => {
expect(result.current.params).toEqual({
spam_protection_proposal_min_tokens: '1',
governance_proposal_updateMarket_minProposerBalance: '2',
});
});
});
it('returns all params', async () => {
const { result } = setup();
expect(result.current.params).toBe(null);
await waitFor(() => {
expect(result.current.params).toEqual({
spam_protection_proposal_min_tokens: '1',
governance_proposal_updateMarket_minProposerBalance: '2',
reward_staking_delegation_payoutDelay: '200',
});
});
});
});

View File

@ -1,5 +1,6 @@
import { gql, useQuery } from '@apollo/client';
import type { NetworkParams } from './__generated__/NetworkParams';
import { useMemo } from 'react';
import type { NetworkParams as NetworkParamsResponse } from './__generated__';
export const NETWORK_PARAMETERS_QUERY = gql`
query NetworkParams {
@ -10,27 +11,127 @@ export const NETWORK_PARAMETERS_QUERY = gql`
}
`;
export const useNetworkParam = (param: string) => {
const { data, loading, error } = useQuery<NetworkParams, never>(
export const NETWORK_PARAMETER_QUERY = gql`
query NetworkParam($key: String!) {
networkParameter(key: $key) {
key
value
}
}
`;
export const NetworkParams = {
blockchains_ethereumConfig: 'blockchains_ethereumConfig',
reward_asset: 'reward_asset',
reward_staking_delegation_payoutDelay:
'reward_staking_delegation_payoutDelay',
governance_proposal_updateMarket_requiredMajority:
'governance_proposal_updateMarket_requiredMajority',
governance_proposal_market_minClose: 'governance_proposal_market_minClose',
governance_proposal_market_maxClose: 'governance_proposal_market_maxClose',
governance_proposal_market_minEnact: 'governance_proposal_market_minEnact',
governance_proposal_market_maxEnact: 'governance_proposal_market_maxEnact',
governance_proposal_updateMarket_minClose:
'governance_proposal_updateMarket_minClose',
governance_proposal_updateMarket_maxClose:
'governance_proposal_updateMarket_maxClose',
governance_proposal_updateMarket_minEnact:
'governance_proposal_updateMarket_minEnact',
governance_proposal_updateMarket_maxEnact:
'governance_proposal_updateMarket_maxEnact',
governance_proposal_asset_minClose: 'governance_proposal_asset_minClose',
governance_proposal_asset_maxClose: 'governance_proposal_asset_maxClose',
governance_proposal_asset_minEnact: 'governance_proposal_asset_minEnact',
governance_proposal_asset_maxEnact: 'governance_proposal_asset_maxEnact',
governance_proposal_updateNetParam_minClose:
'governance_proposal_updateNetParam_minClose',
governance_proposal_updateNetParam_maxClose:
'governance_proposal_updateNetParam_maxClose',
governance_proposal_updateNetParam_minEnact:
'governance_proposal_updateNetParam_minEnact',
governance_proposal_updateNetParam_maxEnact:
'governance_proposal_updateNetParam_maxEnact',
governance_proposal_freeform_minClose:
'governance_proposal_freeform_minClose',
governance_proposal_freeform_maxClose:
'governance_proposal_freeform_maxClose',
governance_proposal_updateMarket_requiredParticipation:
'governance_proposal_updateMarket_requiredParticipation',
governance_proposal_updateMarket_minProposerBalance:
'governance_proposal_updateMarket_minProposerBalance',
governance_proposal_market_requiredMajority:
'governance_proposal_market_requiredMajority',
governance_proposal_market_requiredParticipation:
'governance_proposal_market_requiredParticipation',
governance_proposal_market_minProposerBalance:
'governance_proposal_market_minProposerBalance',
governance_proposal_asset_requiredMajority:
'governance_proposal_asset_requiredMajority',
governance_proposal_asset_requiredParticipation:
'governance_proposal_asset_requiredParticipation',
governance_proposal_asset_minProposerBalance:
'governance_proposal_asset_minProposerBalance',
governance_proposal_updateNetParam_requiredMajority:
'governance_proposal_updateNetParam_requiredMajority',
governance_proposal_updateNetParam_requiredParticipation:
'governance_proposal_updateNetParam_requiredParticipation',
governance_proposal_updateNetParam_minProposerBalance:
'governance_proposal_updateNetParam_minProposerBalance',
governance_proposal_freeform_requiredParticipation:
'governance_proposal_freeform_requiredParticipation',
governance_proposal_freeform_requiredMajority:
'governance_proposal_freeform_requiredMajority',
governance_proposal_freeform_minProposerBalance:
'governance_proposal_freeform_minProposerBalance',
validators_delegation_minAmount: 'validators_delegation_minAmount',
spam_protection_proposal_min_tokens: 'spam_protection_proposal_min_tokens',
market_liquidity_stakeToCcySiskas: 'market_liquidity_stakeToCcySiskas',
} as const;
type Params = typeof NetworkParams;
export type NetworkParamsKey = Params[keyof Params];
type Result = {
[key in keyof Params]: string;
};
export const useNetworkParams = <T extends NetworkParamsKey[]>(params?: T) => {
const { data, loading, error } = useQuery<NetworkParamsResponse, never>(
NETWORK_PARAMETERS_QUERY
);
const foundParams = data?.networkParameters?.filter((p) => param === p.key);
const paramsObj = useMemo(() => {
if (!data?.networkParameters) return null;
return data.networkParameters
.map((p) => ({
...p,
key: p.key.split('.').join('_'),
}))
.filter((p) => {
if (params === undefined || params.length === 0) return true;
if (params.includes(p.key as NetworkParamsKey)) return true;
return false;
})
.reduce((obj, p) => {
obj[p.key] = p.value;
return obj;
}, {} as { [key: string]: string });
}, [data, params]);
return {
data: foundParams ? foundParams.map((f) => f.value) : null,
params: paramsObj as Pick<Result, T[number]>,
loading,
error,
};
};
export const useNetworkParams = (params: string[]) => {
const { data, loading, error } = useQuery<NetworkParams, never>(
NETWORK_PARAMETERS_QUERY
);
const foundParams = data?.networkParameters
?.filter((p) => params.includes(p.key))
.sort((a, b) => params.indexOf(a.key) - params.indexOf(b.key));
export const useNetworkParam = (param: NetworkParamsKey) => {
const { data, loading, error } = useQuery(NETWORK_PARAMETER_QUERY, {
variables: {
key: param,
},
});
return {
data: foundParams ? foundParams.map((f) => f.value) : null,
param: data?.networkParameter ? data.networkParameter.value : null,
loading,
error,
};

View File

@ -36,3 +36,12 @@ export const maxSafe = (max: BigNumber) => (value: string) => {
}
return true;
};
export const suitableForSyntaxHighlighter = (str: string) => {
try {
const test = JSON.parse(str);
return test && Object.keys(test).length > 0;
} catch (e) {
return false;
}
};

View File

@ -34,6 +34,9 @@ const vegaCustomClasses = plugin(function ({ addUtilities }) {
'.clip-path-rounded': {
clipPath: 'circle(50%)',
},
'.color-scheme-dark': {
colorScheme: 'dark',
},
});
});

View File

@ -4,9 +4,10 @@ import type { ReactNode } from 'react';
export interface FormGroupProps {
children: ReactNode;
className?: string;
label: string; // For accessibility reasons this must always be set for screen readers. If you want it to not show, then add labelClassName="sr-only"
label: string; // For accessibility reasons this must always be set for screen readers. If you want it to not show, then use the hideLabel prop"
labelFor: string; // Same as above
hideLabel?: boolean;
labelDescription?: string;
labelAlign?: 'left' | 'right';
}
@ -15,6 +16,7 @@ export const FormGroup = ({
className,
label,
labelFor,
labelDescription,
labelAlign = 'left',
hideLabel = false,
}: FormGroupProps) => {
@ -28,6 +30,9 @@ export const FormGroup = ({
{label && (
<label htmlFor={labelFor} className={labelClasses}>
{label}
{labelDescription && (
<div className="font-light mt-1">{labelDescription}</div>
)}
</label>
)}
{children}

View File

@ -36,9 +36,9 @@ Default.args = {
labelFor: 'labelFor',
};
export const Error = Template.bind({});
Error.args = {
export const WithLabelDescription = Template.bind({});
WithLabelDescription.args = {
label: 'Label',
labelFor: 'labelFor',
hasError: true,
labelDescription: 'Description text',
};

View File

@ -7,7 +7,7 @@ export default {
} as Meta;
const Template: Story = (args) => (
<FormGroup labelClassName="sr-only" label="Hello" labelFor={args.id}>
<FormGroup label="Hello" labelFor={args.id}>
<Input value="I type words" {...args} />
</FormGroup>
);
@ -51,6 +51,8 @@ export const TypeDateTime = Template.bind({});
TypeDateTime.args = {
type: 'datetime-local',
id: 'input-datetime-local',
min: '2022-09-05T11:29:17',
max: '2023-09-05T10:29:49',
};
export const IconPrepend = Template.bind({});

View File

@ -130,10 +130,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
const hasPrepended = !!(prependIconName || prependElement);
const hasAppended = !!(appendIconName || appendElement);
const inputClassName = classNames('appearance-none', className, {
'pl-10': hasPrepended,
'pr-10': hasAppended,
});
const inputClassName = classNames(
'appearance-none dark:color-scheme-dark',
className,
{
'pl-10': hasPrepended,
'pr-10': hasAppended,
}
);
const input = (
<input

View File

@ -79,7 +79,7 @@ export interface WithdrawSubmissionBody extends BaseTransaction {
};
}
interface ProposalNewMarketTerms {
export interface ProposalNewMarketTerms {
newMarket: {
changes: {
decimalPlaces: string;
@ -119,7 +119,7 @@ interface ProposalNewMarketTerms {
enactmentTimestamp: number;
}
interface ProposalUpdateMarketTerms {
export interface ProposalUpdateMarketTerms {
updateMarket: {
marketId: string;
changes: {
@ -141,7 +141,7 @@ interface ProposalUpdateMarketTerms {
enactmentTimestamp: number;
}
interface ProposalNetworkParameterTerms {
export interface ProposalNetworkParameterTerms {
updateNetworkParameter: {
changes: {
key: string;
@ -152,12 +152,12 @@ interface ProposalNetworkParameterTerms {
enactmentTimestamp: number;
}
interface ProposalFreeformTerms {
export interface ProposalFreeformTerms {
newFreeform: Record<string, never>;
closingTimestamp: number;
}
interface ProposalNewAssetTerms {
export interface ProposalNewAssetTerms {
newAsset: {
changes: {
name: string;

View File

@ -55,6 +55,7 @@
"i18next": "^20.3.5",
"i18next-browser-languagedetector": "^6.1.2",
"immer": "^9.0.12",
"iso8601-duration": "^2.1.1",
"js-sha3": "^0.8.0",
"lodash": "^4.17.21",
"next": "12.2.3",

View File

@ -14443,6 +14443,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
iso8601-duration@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/iso8601-duration/-/iso8601-duration-2.1.1.tgz#88d9e481525b50e57840bc93fb8a1727a7d849d2"
integrity sha512-VGGpW30/R57FpG1J7RqqKBAaK7lIiudlZkQ5tRoO9hNlKYQNnhs60DQpXlPFBmp6I+kJ61PHkI3f/T7cR4wfbw==
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"