feat(2034): governance home (#2299)

* feat(2034): quickly-assembled governance home

* feat(2034): tweaks

* feat(2034): tidied up new homepage

* feat(2034): e2e test tweak

* feat(2034): tweaks from PR comments and adjusted epoch progress bg-colour

* feat(2034): fix linting
This commit is contained in:
Sam Keen 2022-12-16 10:11:14 +00:00 committed by GitHub
parent 070f2a2045
commit 0a3509faad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
129 changed files with 526 additions and 260 deletions

View File

@ -18,7 +18,7 @@ context(
});
it('should have GOVERNANCE header visible', function () {
cy.verify_page_header('Governance');
cy.verify_page_header('Proposals');
});
it('should be able to see a working link for - find out more about Vega governance', function () {

View File

@ -3,24 +3,28 @@ import classNames from 'classnames';
interface HeadingProps {
title?: string;
centerContent?: boolean;
marginTop?: boolean;
marginBottom?: boolean;
}
export const Heading = ({
title,
centerContent = true,
marginTop = true,
marginBottom = true,
}: HeadingProps) => {
if (!title) return null;
return (
<header
className={classNames('mt-10 mb-6', {
className={classNames('mb-6', {
'mx-auto': centerContent,
'mt-10': marginTop,
})}
>
<h1
className={classNames('font-alpha calt text-5xl', {
'mt-0': !marginTop,
'mb-0': !marginBottom,
})}
>

View File

@ -16,7 +16,7 @@ export const ProgressBar = ({ value }: ProgressBarProps) => {
className="relative h-2 bg-neutral-600 rounded-full overflow-hidden"
>
<div
className="rounded-full bg-clouds bg-vega-yellow h-full absolute transition-[width] ease-in-out"
className="rounded-full bg-gradient-to-r from-[#4e05cf] to-vega-pink h-full absolute transition-[width] ease-in-out"
style={{ width }}
/>
</div>

View File

@ -0,0 +1,24 @@
import type { ObservableQuery } from '@apollo/client';
import { useEffect } from 'react';
export const useRefreshValidators = (
epochExpiry: string | undefined,
refetch: ObservableQuery['refetch']
) => {
return useEffect(() => {
const epochInterval = setInterval(() => {
if (!epochExpiry) return;
const now = Date.now();
const expiry = new Date(epochExpiry).getTime();
if (now > expiry) {
refetch();
clearInterval(epochInterval);
}
}, 10000);
return () => {
clearInterval(epochInterval);
};
}, [refetch, epochExpiry]);
};

View File

@ -11,7 +11,7 @@
"pageTitle404": "Page not found",
"pageTitleNotPermitted": "Can not proceed!",
"pageTitleDisassociate": "Disassociate $VEGA tokens from a Vega key",
"pageTitleGovernance": "Governance",
"pageTitleProposals": "Proposals",
"pageTitleDepositLp": "Deposit liquidity token for $VEGA rewards",
"pageTitleWithdrawLp": "Withdraw SLP and Rewards",
"pageTitleRewards": "Rewards",
@ -530,6 +530,8 @@
"validatorTableIntro": "View the validator profile pitches and discussion",
"onTheForum": "on the forum",
"readMoreStaking": "Read more about staking on Vega",
"readMoreGovernance": "Read more about Vega governance",
"readMoreValidators": "Read more about validators",
"networkDown": "This site is not currently connecting to the network please try again later.",
"ethTransactionModalTitle": "Ethereum Transactions",
"confirmed": "Confirmed",
@ -719,5 +721,13 @@
"Score": "Score",
"performancePenalty": "Performance penalty",
"overstaked": "Overstaked",
"overstakedPenalty": "Overstaked penalty"
"overstakedPenalty": "Overstaked penalty",
"homeProposalsIntro": "Decisions on the Vega network are on-chain, with tokenholders creating proposals that other tokenholders vote to approve or reject.",
"homeProposalsButtonText": "Browse, vote, and propose",
"homeValidatorsIntro": "Vega runs on a delegated proof of stake blockchain, where validators earn fees for validating block transactions. Tokenholders can nominate validators by staking tokens to them.",
"homeValidatorsButtonText": "Browse, and stake",
"homeRewardsIntro": "Track rewards you've earned for trading, liquidity provision, market creation, and staking.",
"homeRewardsButtonText": "See rewards",
"homeVegaTokenIntro": "VEGA Token is a governance asset used to make and vote on proposals, and nominate validators.",
"homeVegaTokenButtonText": "Manage tokens"
}

View File

@ -1,147 +1,219 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Heading, SubHeading } from '../../components/heading';
import { ExternalLinks } from '@vegaprotocol/react-helpers';
import { useAppState } from '../../contexts/app-state/app-state-context';
import {
AsyncRenderer,
Button,
ExternalLink,
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { useDocumentTitle } from '../../hooks/use-document-title';
import type { RouteChildProps } from '..';
import { useRefreshValidators } from '../../hooks/use-refresh-validators';
import { ProposalsListItem } from '../proposals/components/proposals-list-item';
import Routes from '../routes';
import { TokenDetails } from './token-details';
import { Button } from '@vegaprotocol/ui-toolkit';
import { toBigNum } from '@vegaprotocol/react-helpers';
import { useNodeDataQuery } from './__generated___/NodeData';
import {
ExternalLinks,
removePaginationWrapper,
} from '@vegaprotocol/react-helpers';
import { useNodesQuery } from '../staking/home/__generated___/Nodes';
import { useProposalsQuery } from '../proposals/proposals/__generated__/Proposals';
import { getNotRejectedProposals } from '../proposals/proposals/proposals-container';
import { Heading } from '../../components/heading';
import * as Schema from '@vegaprotocol/types';
import type { RouteChildProps } from '..';
import type { ProposalFieldsFragment } from '../proposals/proposals/__generated__/Proposals';
import type { NodesFragmentFragment } from '../staking/home/__generated___/Nodes';
const Home = ({ name }: RouteChildProps) => {
useDocumentTitle(name);
const nodesToShow = 6;
const HomeProposals = ({
proposals,
}: {
proposals: ProposalFieldsFragment[];
}) => {
const { t } = useTranslation();
const { appState } = useAppState();
const { data } = useNodeDataQuery();
const totalAssociated = React.useMemo(() => {
return toBigNum(data?.nodeData?.stakedTotal || '0', appState.decimals);
}, [appState.decimals, data?.nodeData?.stakedTotal]);
return (
<>
<Heading title={t('pageTitleHome')} />
<HomeSection>
<TokenDetails
totalSupply={appState.totalSupply}
totalAssociated={totalAssociated}
/>
</HomeSection>
<HomeSection>
<SubHeading title={t('Token Vesting')} />
<p>
{t(
'The vesting contract holds VEGA tokens until they have become unlocked.'
)}
</p>
<p>
<Trans
i18nKey="Tokens are held in different <trancheLink>Tranches</trancheLink>. Each tranche has its own schedule for how the tokens are unlocked."
components={{
trancheLink: (
<Link
data-testid="tranches-link"
to={Routes.TRANCHES}
className="underline text-white"
/>
),
}}
/>
</p>
<p>
{t(
'Once unlocked they can be redeemed from the contract so that you can transfer them between wallets.'
)}
</p>
<Link to={Routes.REDEEM}>
<Button
variant="primary"
size="md"
data-testid="check-vesting-page-btn"
>
{t('Check to see if you can redeem unlocked VEGA tokens')}
</Button>
<section className="mb-16" data-testid="home-proposals">
<Heading title={t('Proposals')} />
<h3 className="mb-6">{t('homeProposalsIntro')}</h3>
<div className="flex items-center mb-8 gap-8">
<Link to={`${Routes.PROPOSALS}`}>
<Button size="md">{t('homeProposalsButtonText')}</Button>
</Link>
</HomeSection>
<HomeSection>
<SubHeading title={t('Use your Vega tokens')} />
<p>
{t(
'To use your tokens on the Vega network they need to be associated with a Vega wallet/key.'
)}
</p>
<p>
{t(
'This can happen both while held in the vesting contract as well as when redeemed.'
)}
</p>
<p>
<a
data-testid="get-vega-wallet-link"
href={ExternalLinks.VEGA_WALLET_URL}
className="underline text-white"
target="_blank"
rel="nofollow noreferrer"
>
{t('Get a Vega wallet')}
</a>
</p>
<p>
<Link
data-testid="associate-vega-tokens-link-on-homepage"
to={`${Routes.VALIDATORS}/associate`}
className="underline text-white"
>
{t('Associate VEGA tokens')}
</Link>
</p>
</HomeSection>
<div className="flex gap-12">
<div className="flex-1">
<HomeSection>
<SubHeading title={t('Staking')} />
<p>
{t(
'VEGA token holders can nominate a validator node and receive staking rewards.'
)}
</p>
<p>
<Link to={Routes.VALIDATORS}>
<Button size="md" data-testid="staking-button-on-homepage">
{t('Nominate a validator')}
</Button>
</Link>
</p>
</HomeSection>
</div>
<div className="flex-1">
<HomeSection>
<SubHeading title={t('Governance')} />
<p>
{t(
'VEGA token holders can vote on proposed changes to the network and create proposals.'
)}
</p>
<p>
<Link to={Routes.PROPOSALS}>
<Button size="md" data-testid="governance-button-on-homepage">
{t('View Governance proposals')}
</Button>
</Link>
</p>
</HomeSection>
</div>
<ExternalLink href={ExternalLinks.GOVERNANCE_PAGE}>
{t(`readMoreGovernance`)}
</ExternalLink>
</div>
</>
<ul data-testid="home-proposal-list">
{proposals.map((proposal) => (
<ProposalsListItem key={proposal.id} proposal={proposal} />
))}
</ul>
</section>
);
};
export default Home;
interface HomeNodesProps {
activeNodes: NodesFragmentFragment[];
consensusNodes: NodesFragmentFragment[];
trimmedActiveNodes: NodesFragmentFragment[];
}
export const HomeSection = ({ children }: { children: React.ReactNode }) => {
return <section className="mb-12">{children}</section>;
const HomeNodes = ({
activeNodes,
consensusNodes,
trimmedActiveNodes,
}: HomeNodesProps) => {
const { t } = useTranslation();
const highlightedNodeData = [
{ title: t('active nodes'), length: activeNodes.length },
{ title: t('consensus nodes'), length: consensusNodes.length },
];
return (
<section className="mb-12" data-testid="home-validators">
<Heading title={t('Validators')} />
<h3 className="mb-6">{t('homeValidatorsIntro')}</h3>
<div className="flex items-center mb-8 gap-8">
<Link to={Routes.STAKING}>
<Button size="md">{t('homeValidatorsButtonText')}</Button>
</Link>
<ExternalLink href={ExternalLinks.VALIDATOR_FORUM}>
{t(`readMoreValidators`)}
</ExternalLink>
</div>
<div className="grid grid-cols-[repeat(6,_1fr)] items-center gap-x-6 gap-y-2">
{highlightedNodeData.map(({ title, length }, index) => (
<div key={index} className="col-span-3">
<Link to={Routes.VALIDATORS}>
<RoundedWrapper paddingBottom={true}>
<div className="flex flex-col items-center m-[-1rem] px-4 py-6 hover:bg-neutral-800">
<span className="text-5xl">{length}</span>
<span className="text-sm uppercase text-neutral-400">
{title}
</span>
</div>
</RoundedWrapper>
</Link>
</div>
))}
{trimmedActiveNodes.map(({ id, avatarUrl, name }) => (
<div key={id} className="col-span-2">
<Link to={`${Routes.VALIDATORS}/${id}`}>
<RoundedWrapper paddingBottom={true} border={false}>
<div className="flex items-center justify-center m-[-1rem] p-4 bg-neutral-900 hover:bg-neutral-800">
{avatarUrl && (
<img
className="h-6 w-6 rounded-full mr-2"
src={avatarUrl}
alt={`Avatar icon for ${name}`}
/>
)}
<span className="text-sm">{name}</span>
</div>
</RoundedWrapper>
</Link>
</div>
))}
</div>
{activeNodes.length > nodesToShow && (
<Link to={Routes.STAKING}>
<span className="underline">
And {activeNodes.length - nodesToShow} more...
</span>
</Link>
)}
</section>
);
};
const GovernanceHome = ({ name }: RouteChildProps) => {
useDocumentTitle(name);
const { t } = useTranslation();
const {
data: proposalsData,
loading: proposalsLoading,
error: proposalsError,
} = useProposalsQuery({
pollInterval: 5000,
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
});
const {
data: validatorsData,
error: validatorsError,
loading: validatorsLoading,
refetch,
} = useNodesQuery();
useRefreshValidators(validatorsData?.epoch.timestamps.expiry, refetch);
const proposals = useMemo(
() =>
proposalsData
? getNotRejectedProposals<ProposalFieldsFragment>(
proposalsData.proposalsConnection
).slice(0, 3)
: [],
[proposalsData]
);
const activeNodes = removePaginationWrapper(
validatorsData?.nodesConnection.edges
);
const trimmedActiveNodes = activeNodes?.slice(0, nodesToShow);
const consensusNodes = activeNodes.filter(
(node) =>
node.rankingScore.status ===
Schema.ValidatorStatus.VALIDATOR_NODE_STATUS_TENDERMINT
);
return (
<AsyncRenderer
loading={proposalsLoading || validatorsLoading}
error={proposalsError || validatorsError}
data={proposalsData && validatorsData}
>
<HomeProposals proposals={proposals} />
<HomeNodes
activeNodes={activeNodes}
consensusNodes={consensusNodes}
trimmedActiveNodes={trimmedActiveNodes}
/>
<section className="grid grid-cols-2 gap-12 mb-16">
<div data-testid="home-rewards">
<Heading title={t('Rewards')} marginTop={false} />
<h3 className="mb-6">{t('homeRewardsIntro')}</h3>
<div className="flex items-center mb-8 gap-4">
<Link to={Routes.REWARDS}>
<Button size="md">{t('homeRewardsButtonText')}</Button>
</Link>
</div>
</div>
<div data-testid="home-vega-token">
<Heading title={t('VEGA Token')} marginTop={false} />
<h3 className="mb-6">{t('homeVegaTokenIntro')}</h3>
<div className="flex items-center mb-8 gap-4">
<Link to={Routes.WITHDRAWALS}>
<Button size="md">{t('homeVegaTokenButtonText')}</Button>
</Link>
</div>
</div>
</section>
</AsyncRenderer>
);
};
export default GovernanceHome;

View File

@ -39,6 +39,7 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'New some market'
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent('New market');
expect(screen.getByTestId('proposal-description')).toHaveTextContent(
'A new some market'
);
@ -66,6 +67,9 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'New market id'
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent(
'Update market'
);
expect(
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
@ -99,6 +103,7 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'New asset: Fake currency'
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent('New asset');
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Symbol: FAKE. ERC20 0x0'
);
@ -125,6 +130,7 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'Unknown proposal'
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent('New asset');
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Symbol: BIA. Max faucet amount mint: 300'
);
@ -153,6 +159,9 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-title')).toHaveTextContent(
'Network parameter'
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent(
'Network parameter'
);
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Network key to Network value'
);
@ -175,6 +184,7 @@ describe('Proposal header', () => {
)
);
expect(screen.getByTestId('proposal-title')).toHaveTextContent('0x0');
expect(screen.getByTestId('proposal-type')).toHaveTextContent('Freeform');
expect(
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
@ -202,6 +212,7 @@ describe('Proposal header', () => {
// For a rationale over 100 chars, we expect the header to be truncated at
// 100 chars with ellipsis and the details-one element to contain the rest.
expect(screen.getByTestId('proposal-title')).toHaveTextContent('0x0');
expect(screen.getByTestId('proposal-type')).toHaveTextContent('Freeform');
expect(screen.getByTestId('proposal-description')).toHaveTextContent(
'Class aptent taciti sociosqu ad litora torquent per conubia'
);
@ -226,6 +237,7 @@ describe('Proposal header', () => {
)
);
expect(screen.getByTestId('proposal-title')).toHaveTextContent('freeform');
expect(screen.getByTestId('proposal-type')).toHaveTextContent('Freeform');
expect(
screen.queryByTestId('proposal-description')
).not.toBeInTheDocument();
@ -247,6 +259,9 @@ describe('Proposal header', () => {
})
)
);
expect(screen.getByTestId('proposal-type')).toHaveTextContent(
'Update asset'
);
expect(screen.getByTestId('proposal-details')).toHaveTextContent(
'Update asset'
);

View File

@ -1,20 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Lozenge } from '@vegaprotocol/ui-toolkit';
import { Intent, Lozenge } from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/react-helpers';
import { SubHeading } from '../../../../components/heading';
import { Heading, SubHeading } from '../../../../components/heading';
import type { ReactNode } from 'react';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
export const ProposalHeader = ({
proposal,
useSubHeading = true,
}: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
useSubHeading?: boolean;
}) => {
const { t } = useTranslation();
const change = proposal?.terms.change;
let details: ReactNode;
let proposalType: ReactNode;
let title = proposal?.rationale.title.trim();
let description = proposal?.rationale.description.trim();
@ -27,6 +30,7 @@ export const ProposalHeader = ({
switch (change?.__typename) {
case 'NewMarket': {
proposalType = t('NewMarket');
details = (
<>
{t('Code')}: {change.instrument.code}.{' '}
@ -45,10 +49,12 @@ export const ProposalHeader = ({
break;
}
case 'UpdateMarket': {
proposalType = t('UpdateMarket');
details = `${t('Market change')}: ${change.marketId}`;
break;
}
case 'NewAsset': {
proposalType = t('NewAsset');
details = (
<>
{t('Symbol')}: {change.symbol}.{' '}
@ -65,6 +71,7 @@ export const ProposalHeader = ({
break;
}
case 'UpdateNetworkParameter': {
proposalType = t('NetworkParameter');
const parametersClasses = 'font-mono leading-none';
details = (
<>
@ -80,10 +87,12 @@ export const ProposalHeader = ({
break;
}
case 'NewFreeform': {
proposalType = t('Freeform');
details = `${t('FreeformProposal')}: ${proposal?.id}`;
break;
}
case 'UpdateAsset': {
proposalType = t('UpdateAsset');
details = (
<>
`${t('Update asset')}`;
@ -96,14 +105,28 @@ export const ProposalHeader = ({
return (
<div className="text-sm mb-2">
<header data-testid="proposal-title">
<SubHeading title={titleContent || t('Unknown proposal')} />
</header>
{description && (
<div className="mb-4" data-testid="proposal-description">
{description}
</div>
)}
<div data-testid="proposal-title">
{useSubHeading ? (
<header>
<SubHeading title={titleContent || t('Unknown proposal')} />
</header>
) : (
<Heading title={titleContent || t('Unknown proposal')} />
)}
</div>
<div className="flex items-center gap-2 mb-4">
{proposalType && (
<div data-testid="proposal-type">
<Lozenge variant={Intent.None}>{proposalType}</Lozenge>
</div>
)}
{description && (
<div data-testid="proposal-description">{description}</div>
)}
</div>
{details && <div data-testid="proposal-details">{details}</div>}
</div>
);

View File

@ -74,7 +74,7 @@ export const Proposal = ({ proposal }: ProposalProps) => {
return (
<AsyncRenderer data={params} loading={loading} error={error}>
<section data-testid="proposal">
<ProposalHeader proposal={proposal} />
<ProposalHeader proposal={proposal} useSubHeading={false} />
<div className="mb-10">
<ProposalChangeTable proposal={proposal} />
</div>

View File

@ -192,7 +192,6 @@ describe('Proposals list item details', () => {
networkParamsQueryMock,
createUserVoteQueryMock(proposal?.id, VoteValue.VALUE_YES),
]);
screen.debug();
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(await screen.findByText('You voted')).toBeInTheDocument();

View File

@ -1,3 +1,4 @@
import { RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalsListItemDetails } from './proposals-list-item-details';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
@ -11,13 +12,11 @@ export const ProposalsListItem = ({ proposal }: ProposalsListItemProps) => {
if (!proposal || !proposal.id) return null;
return (
<li
className="py-4 border-b border-neutral-400 last:border-0"
id={proposal.id}
data-testid="proposals-list-item"
>
<ProposalHeader proposal={proposal} />
<ProposalsListItemDetails proposal={proposal} />
<li id={proposal.id} data-testid="proposals-list-item">
<RoundedWrapper paddingBottom={true}>
<ProposalHeader proposal={proposal} />
<ProposalsListItemDetails proposal={proposal} />
</RoundedWrapper>
</li>
);
};

View File

@ -81,7 +81,7 @@ afterAll(() => {
describe('Proposals list', () => {
it('Render a page title and link to the make proposal form', () => {
render(renderComponent([]));
expect(screen.getByText('Governance')).toBeInTheDocument();
expect(screen.getByText('Proposals')).toBeInTheDocument();
expect(screen.getByTestId('new-proposal-link')).toBeInTheDocument();
});

View File

@ -51,7 +51,7 @@ export const ProposalsList = ({ proposals }: ProposalsListProps) => {
<Heading
centerContent={false}
marginBottom={false}
title={t('pageTitleGovernance')}
title={t('pageTitleProposals')}
/>
<Link
className="xs:justify-self-end"

Some files were not shown because too many files have changed in this diff Show More