Merge branch 'develop' into 5554-enhancements-to-transfer-fee

This commit is contained in:
Bartłomiej Głownia 2024-02-06 14:36:35 +01:00
commit 80e30e7678
No known key found for this signature in database
GPG Key ID: A622E438A7075295
50 changed files with 1434 additions and 672 deletions

View File

@ -1,4 +1,4 @@
export type HashProps = { export type HashProps = React.HTMLProps<HTMLSpanElement> & {
text: string; text: string;
truncate?: boolean; truncate?: boolean;
}; };

View File

@ -2,4 +2,5 @@ export { default as BlockLink } from './block-link/block-link';
export { default as PartyLink } from './party-link/party-link'; export { default as PartyLink } from './party-link/party-link';
export { default as NodeLink } from './node-link/node-link'; export { default as NodeLink } from './node-link/node-link';
export { default as MarketLink } from './market-link/market-link'; export { default as MarketLink } from './market-link/market-link';
export { default as NetworkParameterLink } from './network-parameter-link/network-parameter-link';
export * from './asset-link/asset-link'; export * from './asset-link/asset-link';

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Routes } from '../../../routes/route-names';
import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react';
import Hash from '../hash';
export type NetworkParameterLinkProps = Partial<ComponentProps<typeof Link>> & {
parameter: string;
};
/**
* Links a given network parameter to the relevant page and anchor on the page
*/
const NetworkParameterLink = ({
parameter,
...props
}: NetworkParameterLinkProps) => {
return (
<Link
className="underline"
{...props}
to={`/${Routes.NETWORK_PARAMETERS}#${parameter}`}
>
<Hash text={parameter} />
</Link>
);
};
export default NetworkParameterLink;

View File

@ -26,7 +26,7 @@ const ProposalLink = ({ id, text }: ProposalLinkProps) => {
>; >;
const base = ENV.dataSources.governanceUrl; const base = ENV.dataSources.governanceUrl;
const label = proposal?.rationale.title || id; const label = proposal?.rationale?.title || id;
return ( return (
<ExternalLink href={`${base}/proposals/${id}`}> <ExternalLink href={`${base}/proposals/${id}`}>

View File

@ -5,5 +5,10 @@ query ExplorerProposalStatus($id: ID!) {
state state
rejectionReason rejectionReason
} }
... on BatchProposal {
id
state
rejectionReason
}
} }
} }

View File

@ -8,7 +8,7 @@ export type ExplorerProposalStatusQueryVariables = Types.Exact<{
}>; }>;
export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal' } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null }; export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null };
export const ExplorerProposalStatusDocument = gql` export const ExplorerProposalStatusDocument = gql`
@ -19,6 +19,11 @@ export const ExplorerProposalStatusDocument = gql`
state state
rejectionReason rejectionReason
} }
... on BatchProposal {
id
state
rejectionReason
}
} }
} }
`; `;

View File

@ -0,0 +1,257 @@
import { render, screen } from '@testing-library/react';
import { BatchItem } from './batch-item';
import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
import type { components } from '../../../../../types/explorer';
type Item = components['schemas']['vegaBatchProposalTermsChange'];
describe('BatchItem', () => {
it('Renders "Unknown proposal type" by default', () => {
const item = {};
render(<BatchItem item={item} />);
expect(screen.getByText('Unknown proposal type')).toBeInTheDocument();
});
it('Renders "Unknown proposal type" for unknown items', () => {
const item = {
newLochNessMonster: {
location: 'Loch Ness',
},
} as unknown as Item;
render(<BatchItem item={item} />);
expect(screen.getByText('Unknown proposal type')).toBeInTheDocument();
});
it('Renders "New spot market"', () => {
const item = {
newSpotMarket: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New spot market')).toBeInTheDocument();
});
it('Renders "Cancel transfer"', () => {
const item = {
cancelTransfer: {
changes: {
transferId: 'transfer123',
},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Cancel transfer')).toBeInTheDocument();
expect(screen.getByText('transf')).toBeInTheDocument();
});
it('Renders "Cancel transfer" without an id', () => {
const item = {
cancelTransfer: {
changes: {},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Cancel transfer')).toBeInTheDocument();
});
it('Renders "New freeform"', () => {
const item = {
newFreeform: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New freeform proposal')).toBeInTheDocument();
});
it('Renders "New market"', () => {
const item = {
newMarket: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New market')).toBeInTheDocument();
});
it('Renders "New transfer"', () => {
const item = {
newTransfer: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('New transfer')).toBeInTheDocument();
});
it('Renders "Update asset" with assetId', () => {
const item = {
updateAsset: {
assetId: 'asset123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update asset')).toBeInTheDocument();
expect(screen.getByText('asset123')).toBeInTheDocument();
});
it('Renders "Update asset" even if assetId is not set', () => {
const item = {
updateAsset: {
assetId: undefined,
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update asset')).toBeInTheDocument();
});
it('Renders "Update market state" with marketId', () => {
const item = {
updateMarketState: {
changes: {
marketId: 'market123',
},
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market state')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update market state" even if marketId is not set', () => {
const item = {
updateMarketState: {
changes: {
marketId: undefined,
},
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market state')).toBeInTheDocument();
});
it('Renders "Update network parameter" with parameter', () => {
const item = {
updateNetworkParameter: {
changes: {
key: 'parameter123',
},
},
};
render(
<MockedProvider>
<MemoryRouter>
<BatchItem item={item} />
</MemoryRouter>
</MockedProvider>
);
expect(screen.getByText('Update network parameter')).toBeInTheDocument();
expect(screen.getByText('parameter123')).toBeInTheDocument();
});
it('Renders "Update network parameter" even if parameter is not set', () => {
const item = {
updateNetworkParameter: {
changes: {
key: undefined,
},
},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update network parameter')).toBeInTheDocument();
});
it('Renders "Update referral program"', () => {
const item = {
updateReferralProgram: {},
};
render(<BatchItem item={item} />);
expect(screen.getByText('Update referral program')).toBeInTheDocument();
});
it('Renders "Update spot market" with marketId', () => {
const item = {
updateSpotMarket: {
marketId: 'market123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update spot market')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update spot market" even if marketId is not set', () => {
const item = {
updateSpotMarket: {
marketId: undefined,
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update spot market')).toBeInTheDocument();
});
it('Renders "Update market" with marketId', () => {
const item = {
updateMarket: {
marketId: 'market123',
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market')).toBeInTheDocument();
expect(screen.getByText('market123')).toBeInTheDocument();
});
it('Renders "Update market" even if marketId is not set', () => {
const item = {
updateMarket: {
marketId: undefined,
},
};
render(
<MemoryRouter>
<MockedProvider>
<BatchItem item={item} />
</MockedProvider>
</MemoryRouter>
);
expect(screen.getByText('Update market')).toBeInTheDocument();
});
it('Renders "Update volume discount program"', () => {
const item = {
updateVolumeDiscountProgram: {},
};
render(<BatchItem item={item} />);
expect(
screen.getByText('Update volume discount program')
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,87 @@
import { t } from '@vegaprotocol/i18n';
import { AssetLink, MarketLink, NetworkParameterLink } from '../../../links';
import type { components } from '../../../../../types/explorer';
import Hash from '../../../links/hash';
type Item = components['schemas']['vegaBatchProposalTermsChange'];
export interface BatchItemProps {
item: Item;
}
/**
* Produces a one line summary for an item in a batch proposal. Could
* easily be adapted to summarise individual proposals, but there is no
* place for that yet.
*
* Details (like IDs) should be shown and linked if available, but handled
* if not available. This is adequate as the ProposalSummary component contains
* a JSON viewer for the full proposal.
*/
export const BatchItem = ({ item }: BatchItemProps) => {
if (item.cancelTransfer) {
const transferId = item?.cancelTransfer?.changes?.transferId || false;
return (
<span>
{t('Cancel transfer')}&nbsp;
{transferId && (
<Hash className="ml-1" truncate={true} text={transferId} />
)}
</span>
);
} else if (item.newFreeform) {
return <span>{t('New freeform proposal')}</span>;
} else if (item.newMarket) {
return <span>{t('New market')}</span>;
} else if (item.newSpotMarket) {
return <span>{t('New spot market')}</span>;
} else if (item.newTransfer) {
return <span>{t('New transfer')}</span>;
} else if (item.updateAsset) {
const assetId = item?.updateAsset?.assetId || false;
return (
<span>
{t('Update asset')}
{assetId && <AssetLink className="ml-1" assetId={assetId} />}
</span>
);
} else if (item.updateMarket) {
const marketId = item?.updateMarket?.marketId || false;
return (
<span>
{t('Update market')}{' '}
{marketId && <MarketLink className="ml-1" id={marketId} />}
</span>
);
} else if (item.updateMarketState) {
const marketId = item?.updateMarketState?.changes?.marketId || false;
return (
<span>
{t('Update market state')}
{marketId && <MarketLink className="ml-1" id={marketId} />}
</span>
);
} else if (item.updateNetworkParameter) {
const param = item?.updateNetworkParameter?.changes?.key || false;
return (
<span>
{t('Update network parameter')}
{param && <NetworkParameterLink className="ml-1" parameter={param} />}
</span>
);
} else if (item.updateReferralProgram) {
return <span>{t('Update referral program')}</span>;
} else if (item.updateSpotMarket) {
const marketId = item?.updateSpotMarket?.marketId || '';
return (
<span>
{t('Update spot market')}
<MarketLink className="ml-1" id={marketId} />
</span>
);
} else if (item.updateVolumeDiscountProgram) {
return <span>{t('Update volume discount program')}</span>;
}
return <span>{t('Unknown proposal type')}</span>;
};

View File

@ -1,6 +1,4 @@
import type { ProposalTerms } from '../tx-proposal';
import { useState } from 'react'; import { useState } from 'react';
import type { components } from '../../../../../types/explorer';
import { JsonViewerDialog } from '../../../dialogs/json-viewer-dialog'; import { JsonViewerDialog } from '../../../dialogs/json-viewer-dialog';
import ProposalLink from '../../../links/proposal-link/proposal-link'; import ProposalLink from '../../../links/proposal-link/proposal-link';
import truncate from 'lodash/truncate'; import truncate from 'lodash/truncate';
@ -9,7 +7,12 @@ import ReactMarkdown from 'react-markdown';
import { ProposalDate } from './proposal-date'; import { ProposalDate } from './proposal-date';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { ProposalTerms } from '../tx-proposal';
import type { components } from '../../../../../types/explorer';
import { BatchItem } from './batch-item';
type Rationale = components['schemas']['vegaProposalRationale']; type Rationale = components['schemas']['vegaProposalRationale'];
type Batch = components['schemas']['v1BatchProposalSubmissionTerms']['changes'];
type ProposalTermsDialog = { type ProposalTermsDialog = {
open: boolean; open: boolean;
@ -21,6 +24,7 @@ interface ProposalSummaryProps {
id: string; id: string;
rationale?: Rationale; rationale?: Rationale;
terms?: ProposalTerms; terms?: ProposalTerms;
batch?: Batch;
} }
/** /**
@ -31,6 +35,7 @@ export const ProposalSummary = ({
id, id,
rationale, rationale,
terms, terms,
batch,
}: ProposalSummaryProps) => { }: ProposalSummaryProps) => {
const [dialog, setDialog] = useState<ProposalTermsDialog>({ const [dialog, setDialog] = useState<ProposalTermsDialog>({
open: false, open: false,
@ -72,6 +77,18 @@ export const ProposalSummary = ({
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
{batch && (
<section className="pt-2 text-sm leading-tight my-3">
<h2 className="text-lg pb-1">{t('Changes')}</h2>
<ol>
{batch.map((change, index) => (
<li className="ml-4 list-decimal" key={`batch-${index}`}>
<BatchItem item={change} />
</li>
))}
</ol>
</section>
)}
<div className="pt-5"> <div className="pt-5">
<button className="underline max-md:hidden mr-5" onClick={openDialog}> <button className="underline max-md:hidden mr-5" onClick={openDialog}>
{t('View terms')} {t('View terms')}

View File

@ -0,0 +1,75 @@
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { sharedHeaderProps, TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import type { components } from '../../../../types/explorer';
import { txSignatureToDeterministicId } from '../lib/deterministic-ids';
import { ProposalSummary } from './proposal/summary';
import Hash from '../../links/hash';
import { t } from '@vegaprotocol/i18n';
export type Proposal = components['schemas']['v1BatchProposalSubmission'];
export type ProposalTerms = components['schemas']['vegaProposalTerms'];
interface TxBatchProposalProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
/**
*
*/
export const TxBatchProposal = ({
txData,
pubKey,
blockData,
}: TxBatchProposalProps) => {
if (!txData || !txData.command.batchProposalSubmission) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
let deterministicId = '';
const proposal: Proposal = txData.command.batchProposalSubmission;
const sig = txData?.signature?.value;
if (sig) {
deterministicId = txSignatureToDeterministicId(sig);
}
return (
<>
<TableWithTbody className="mb-8" allowWrap={true}>
<TableRow modifier="bordered">
<TableCell {...sharedHeaderProps}>{t('Type')}</TableCell>
<TableCell>{t('Batch proposal')}</TableCell>
</TableRow>
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
hideTypeRow={true}
/>
<TableRow modifier="bordered">
<TableCell>{t('Batch size')}</TableCell>
<TableCell>
{proposal.terms?.changes?.length || t('No changes')}
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>{t('Proposal ID')}</TableCell>
<TableCell>
<Hash text={deterministicId} />
</TableCell>
</TableRow>
</TableWithTbody>
{proposal && (
<ProposalSummary
id={deterministicId}
rationale={proposal?.rationale}
terms={proposal.terms}
batch={proposal.terms?.changes}
/>
)}
</>
);
};

View File

@ -33,6 +33,7 @@ import { TxDetailsApplyReferralCode } from './tx-apply-referral-code';
import { TxDetailsUpdateReferralSet } from './tx-update-referral-set'; import { TxDetailsUpdateReferralSet } from './tx-update-referral-set';
import { TxDetailsJoinTeam } from './tx-join-team'; import { TxDetailsJoinTeam } from './tx-join-team';
import { TxDetailsUpdateMarginMode } from './tx-update-margin-mode'; import { TxDetailsUpdateMarginMode } from './tx-update-margin-mode';
import { TxBatchProposal } from './tx-batch-proposal';
interface TxDetailsWrapperProps { interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined; txData: BlockExplorerTransactionResult | undefined;
@ -136,6 +137,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsJoinTeam; return TxDetailsJoinTeam;
case 'Update Margin Mode': case 'Update Margin Mode':
return TxDetailsUpdateMarginMode; return TxDetailsUpdateMarginMode;
case 'Batch Proposal':
return TxBatchProposal;
default: default:
return TxDetailsGeneric; return TxDetailsGeneric;
} }

View File

@ -20,6 +20,7 @@ export type FilterOption =
| 'Amend Order' | 'Amend Order'
| 'Apply Referral Code' | 'Apply Referral Code'
| 'Batch Market Instructions' | 'Batch Market Instructions'
| 'Batch Proposal'
| 'Cancel LiquidityProvision Order' | 'Cancel LiquidityProvision Order'
| 'Cancel Order' | 'Cancel Order'
| 'Cancel Transfer Funds' | 'Cancel Transfer Funds'
@ -67,7 +68,13 @@ export const filterOptions: Record<string, FilterOption[]> = {
'Cancel Transfer Funds', 'Cancel Transfer Funds',
'Withdraw', 'Withdraw',
], ],
Governance: ['Delegate', 'Undelegate', 'Vote on Proposal', 'Proposal'], Governance: [
'Batch Proposal',
'Delegate',
'Undelegate',
'Vote on Proposal',
'Proposal',
],
Referrals: [ Referrals: [
'Apply Referral Code', 'Apply Referral Code',
'Create Referral Set', 'Create Referral Set',

View File

@ -71,18 +71,27 @@ const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
if (status === 'confirmed') { if (status === 'confirmed') {
return ( return (
<div className="flex flex-col items-start gap-2"> <div
className="flex flex-col items-start gap-2"
data-testid="team-creation-success-message"
>
<p className="text-sm">{t('Team creation transaction successful')}</p> <p className="text-sm">{t('Team creation transaction successful')}</p>
{code && ( {code && (
<> <>
<p className="text-sm"> <p className="text-sm">
Your team ID is:{' '} Your team ID is:{' '}
<span className="font-mono break-all">{code}</span> <span
className="font-mono break-all"
data-testid="team-id-display"
>
{code}
</span>
</p> </p>
<TradingAnchorButton <TradingAnchorButton
href={Links.COMPETITIONS_TEAM(code)} href={Links.COMPETITIONS_TEAM(code)}
intent={Intent.Info} intent={Intent.Info}
size="small" size="small"
data-testid="view-team-button"
> >
{t('View team')} {t('View team')}
</TradingAnchorButton> </TradingAnchorButton>

View File

@ -16,6 +16,8 @@ import { CompetitionsLeaderboard } from '../../components/competitions/competiti
import { useTeams } from '../../lib/hooks/use-teams'; import { useTeams } from '../../lib/hooks/use-teams';
import take from 'lodash/take'; import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title'; import { usePageTitle } from '../../lib/hooks/use-page-title';
import { TeamCard } from '../../components/competitions/team-card';
import { useMyTeam } from '../../lib/hooks/use-my-team';
export const CompetitionsHome = () => { export const CompetitionsHome = () => {
const t = useT(); const t = useT();
@ -33,6 +35,13 @@ export const CompetitionsHome = () => {
const { data: teamsData, loading: teamsLoading } = useTeams(); const { data: teamsData, loading: teamsLoading } = useTeams();
const {
team: myTeam,
stats: myTeamStats,
games: myTeamGames,
rank: myTeamRank,
} = useMyTeam();
return ( return (
<ErrorBoundary> <ErrorBoundary>
<CompetitionsHeader title={t('Competitions')}> <CompetitionsHeader title={t('Competitions')}>
@ -43,65 +52,83 @@ export const CompetitionsHome = () => {
</p> </p>
</CompetitionsHeader> </CompetitionsHeader>
{/** Get started */} {/** Team card */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2> {myTeam ? (
<>
<h2 className="text-2xl mb-6">{t('My team')}</h2>
<div className="mb-12">
<TeamCard
team={myTeam}
rank={myTeamRank}
stats={myTeamStats}
games={myTeamGames}
/>
</div>
</>
) : (
<>
{/** Get started */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2>
<CompetitionsActionsContainer> <CompetitionsActionsContainer>
<CompetitionsAction <CompetitionsAction
variant="A" variant="A"
title={t('Create a team')} title={t('Create a team')}
description={t( description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)} )}
actionElement={ actionElement={
<TradingButton <TradingButton
intent={Intent.Primary} intent={Intent.Primary}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM()); navigate(Links.COMPETITIONS_CREATE_TEAM());
}} }}
> data-testId="create-public-team-button"
{t('Create a public team')} >
</TradingButton> {t('Create a public team')}
} </TradingButton>
/> }
<CompetitionsAction />
variant="B" <CompetitionsAction
title={t('Solo team / lone wolf')} variant="B"
description={t( title={t('Solo team / lone wolf')}
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' description={t(
)} 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
actionElement={ )}
<TradingButton actionElement={
intent={Intent.Primary} <TradingButton
onClick={(e) => { intent={Intent.Primary}
e.preventDefault(); onClick={(e) => {
navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO()); e.preventDefault();
}} navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO());
> }}
{t('Create a private team')} >
</TradingButton> {t('Create a private team')}
} </TradingButton>
/> }
<CompetitionsAction />
variant="C" <CompetitionsAction
title={t('Join a team')} variant="C"
description={t( title={t('Join a team')}
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.' description={t(
)} 'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
actionElement={ )}
<TradingButton actionElement={
intent={Intent.Primary} <TradingButton
onClick={(e) => { intent={Intent.Primary}
e.preventDefault(); onClick={(e) => {
navigate(Links.COMPETITIONS_TEAMS()); e.preventDefault();
}} navigate(Links.COMPETITIONS_TEAMS());
> }}
{t('Choose a team')} >
</TradingButton> {t('Choose a team')}
} </TradingButton>
/> }
</CompetitionsActionsContainer> />
</CompetitionsActionsContainer>
</>
)}
{/** List of available games */} {/** List of available games */}
<h2 className="text-2xl mb-6">{t('Games')}</h2> <h2 className="text-2xl mb-6">{t('Games')}</h2>

View File

@ -105,7 +105,11 @@ export const JoinButton = ({
// Party is in a team, but not this one // Party is in a team, but not this one
else if (partyTeam && partyTeam.teamId !== team.teamId) { else if (partyTeam && partyTeam.teamId !== team.teamId) {
return ( return (
<Button onClick={() => onJoin('switch')} intent={Intent.Primary}> <Button
onClick={() => onJoin('switch')}
intent={Intent.Primary}
data-testid="switch-team-button"
>
{t('Switch team')}{' '} {t('Switch team')}{' '}
</Button> </Button>
); );
@ -215,7 +219,11 @@ const DialogContent = ({
</> </>
)} )}
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button onClick={joinTeam} intent={Intent.Success}> <Button
onClick={joinTeam}
intent={Intent.Success}
data-testid="confirm-switch-button"
>
{t('Confirm')} {t('Confirm')}
</Button> </Button>
<Button onClick={onCancel} intent={Intent.Danger}> <Button onClick={onCancel} intent={Intent.Danger}>

View File

@ -6,6 +6,8 @@ import {
TextArea, TextArea,
TradingButton, TradingButton,
Intent, Intent,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { URL_REGEX, isValidVegaPublicKey } from '@vegaprotocol/utils'; import { URL_REGEX, isValidVegaPublicKey } from '@vegaprotocol/utils';
@ -17,6 +19,8 @@ import type {
UpdateReferralSet, UpdateReferralSet,
Status, Status,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import classNames from 'classnames';
import { useLayoutEffect, useState } from 'react';
export type FormFields = { export type FormFields = {
id: string; id: string;
@ -111,9 +115,12 @@ export const TeamForm = ({
<form onSubmit={handleSubmit(sendTransaction)}> <form onSubmit={handleSubmit(sendTransaction)}>
<input type="hidden" {...register('id')} /> <input type="hidden" {...register('id')} />
<TradingFormGroup label={t('Team name')} labelFor="name"> <TradingFormGroup label={t('Team name')} labelFor="name">
<TradingInput {...register('name', { required: t('Required') })} /> <TradingInput
{...register('name', { required: t('Required') })}
data-testid="team-name-input"
/>
{errors.name?.message && ( {errors.name?.message && (
<TradingInputError forInput="name"> <TradingInputError forInput="name" data-testid="team-name-error">
{errors.name.message} {errors.name.message}
</TradingInputError> </TradingInputError>
)} )}
@ -129,9 +136,10 @@ export const TeamForm = ({
{...register('url', { {...register('url', {
pattern: { value: URL_REGEX, message: t('Invalid URL') }, pattern: { value: URL_REGEX, message: t('Invalid URL') },
})} })}
data-testid="team-url-input"
/> />
{errors.url?.message && ( {errors.url?.message && (
<TradingInputError forInput="url"> <TradingInputError forInput="url" data-testid="team-url-error">
{errors.url.message} {errors.url.message}
</TradingInputError> </TradingInputError>
)} )}
@ -148,9 +156,13 @@ export const TeamForm = ({
message: t('Invalid image URL'), message: t('Invalid image URL'),
}, },
})} })}
data-testid="avatar-url-input"
/> />
{errors.avatarUrl?.message && ( {errors.avatarUrl?.message && (
<TradingInputError forInput="avatarUrl"> <TradingInputError
forInput="avatarUrl"
data-testid="avatar-url-error"
>
{errors.avatarUrl.message} {errors.avatarUrl.message}
</TradingInputError> </TradingInputError>
)} )}
@ -175,6 +187,7 @@ export const TeamForm = ({
onCheckedChange={(value) => { onCheckedChange={(value) => {
field.onChange(value); field.onChange(value);
}} }}
data-testid="team-private-checkbox"
/> />
); );
}} }}
@ -203,9 +216,13 @@ export const TeamForm = ({
}, },
}, },
})} })}
data-testid="team-allow-list-textarea"
/> />
{errors.allowList?.message && ( {errors.allowList?.message && (
<TradingInputError forInput="avatarUrl"> <TradingInputError
forInput="avatarUrl"
data-testid="team-allow-list-error"
>
{errors.allowList.message} {errors.allowList.message}
</TradingInputError> </TradingInputError>
)} )}
@ -239,16 +256,57 @@ const SubmitButton = ({
text = t('Update'); text = t('Update');
} }
let confirmedText = t('Created');
if (type === TransactionType.UpdateReferralSet) {
confirmedText = t('Updated');
}
if (status === 'requested') { if (status === 'requested') {
text = t('Confirm in wallet...'); text = t('Confirm in wallet...');
} else if (status === 'pending') { } else if (status === 'pending') {
text = t('Confirming transaction...'); text = t('Confirming transaction...');
} }
const [showConfirmed, setShowConfirmed] = useState<boolean>(false);
useLayoutEffect(() => {
let to: ReturnType<typeof setTimeout>;
if (status === 'confirmed' && !showConfirmed) {
to = setTimeout(() => {
setShowConfirmed(true);
}, 100);
}
return () => {
clearTimeout(to);
};
}, [showConfirmed, status]);
const confirmed = (
<span
className={classNames('text-sm transition-opacity opacity-0', {
'opacity-100': showConfirmed,
})}
>
<VegaIcon
name={VegaIconNames.TICK}
size={18}
className="text-vega-green-500"
/>{' '}
{confirmedText}
</span>
);
return ( return (
<TradingButton type="submit" intent={Intent.Info} disabled={disabled}> <div className="flex gap-2 items-baseline">
{text} <TradingButton
</TradingButton> type="submit"
intent={Intent.Info}
disabled={disabled}
data-testid="team-form-submit-button"
>
{text}
</TradingButton>
{status === 'confirmed' && confirmed}
</div>
); );
}; };

View File

@ -1,16 +1,24 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import { type Team } from '../../lib/hooks/use-team'; import { type Team } from '../../lib/hooks/use-team';
import { type ComponentProps } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit'; import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links'; import { Links } from '../../lib/links';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
export const UpdateTeamButton = ({ team }: { team: Team }) => { export const UpdateTeamButton = ({
team,
size = 'medium',
}: {
team: Pick<Team, 'teamId' | 'referrer'>;
size?: ComponentProps<typeof TradingAnchorButton>['size'];
}) => {
const t = useT(); const t = useT();
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
if (pubKey && !isReadOnly && pubKey === team.referrer) { if (pubKey && !isReadOnly && pubKey === team.referrer) {
return ( return (
<TradingAnchorButton <TradingAnchorButton
size={size}
data-testid="update-team-button" data-testid="update-team-button"
href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)} href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)}
intent={Intent.Info} intent={Intent.Info}

View File

@ -5,7 +5,11 @@ export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT = export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent'; 'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
export const Box = (props: HTMLAttributes<HTMLDivElement>) => { export const Box = ({
children,
backgroundImage,
...props
}: HTMLAttributes<HTMLDivElement> & { backgroundImage?: string }) => {
return ( return (
<div <div
{...props} {...props}
@ -13,9 +17,22 @@ export const Box = (props: HTMLAttributes<HTMLDivElement>) => {
BORDER_COLOR, BORDER_COLOR,
GRADIENT, GRADIENT,
'border rounded-lg', 'border rounded-lg',
'p-6', 'relative p-6 overflow-hidden',
props.className props.className
)} )}
/> >
{Boolean(backgroundImage?.length) && (
<div
className={classNames(
'pointer-events-none',
'bg-no-repeat bg-center bg-[length:500px_500px]',
'absolute top-0 left-0 w-full h-full -z-10 opacity-30 blur-lg'
)}
style={{ backgroundImage: `url("${backgroundImage}")` }}
></div>
)}
{children}
</div>
); );
}; };

View File

@ -3,7 +3,7 @@ import classNames from 'classnames';
const NUM_AVATARS = 20; const NUM_AVATARS = 20;
const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png'; const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png';
const getFallbackAvatar = (teamId: string) => { export const getFallbackAvatar = (teamId: string) => {
const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1) const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1)
.toString() .toString()
.padStart(2, '0'); // between 01 - 20 .padStart(2, '0'); // between 01 - 20

View File

@ -0,0 +1,154 @@
import { type TeamGame, type TeamStats } from '../../lib/hooks/use-team';
import { type TeamsFieldsFragment } from '../../lib/hooks/__generated__/Teams';
import { TeamAvatar, getFallbackAvatar } from './team-avatar';
import { FavoriteGame, Stat } from './team-stats';
import { useT } from '../../lib/use-t';
import { formatNumberRounded } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { Box } from './box';
import { Intent, Tooltip, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links';
import orderBy from 'lodash/orderBy';
import { take } from 'lodash';
import { DispatchMetricLabels } from '@vegaprotocol/types';
import classNames from 'classnames';
import { UpdateTeamButton } from '../../client-pages/competitions/update-team-button';
export const TeamCard = ({
rank,
team,
stats,
games,
}: {
rank: number;
team: TeamsFieldsFragment;
stats?: TeamStats;
games?: TeamGame[];
}) => {
const t = useT();
const lastGames = take(
orderBy(
games?.map((g) => ({
rank: g.team.rank,
metric: g.team.rewardMetric,
epoch: g.epoch,
})),
(i) => i.epoch,
'desc'
),
5
);
return (
<div
className={classNames(
'gap-6 grid grid-cols-1 grid-rows-1',
'md:grid-cols-3'
)}
>
{/** Card */}
<Box
backgroundImage={team.avatarUrl || getFallbackAvatar(team.teamId)}
className="flex flex-col items-center gap-3 min-w-[80px] lg:min-w-[112px]"
>
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<h1 className="calt lg:text-2xl" data-testid="team-name">
{team.name}
</h1>
{games && <FavoriteGame games={games} noLabel />}
<TradingAnchorButton
size="extra-small"
intent={Intent.Primary}
href={Links.COMPETITIONS_TEAM(team.teamId)}
>
{t('Profile')}
</TradingAnchorButton>
<UpdateTeamButton team={team} size="extra-small" />
</Box>
{/** Tiles */}
<Box className="w-full md:col-span-2">
<div
className={classNames(
'grid gap-3 w-full mb-4',
'md:grid-cols-3 md:grid-rows-2',
'grid-cols-2 grid-rows-3'
)}
>
<Stat
className="flex flex-col-reverse"
value={rank}
label={t('Rank')}
valueTestId="team-rank"
/>
<Stat
className="flex flex-col-reverse"
value={team.totalMembers || 0}
label={t('Members')}
valueTestId="members-count-stat"
/>
<Stat
className="flex flex-col-reverse"
value={stats?.totalGamesPlayed || 0}
label={t('Total games')}
valueTestId="total-games-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumVolume
? formatNumberRounded(
new BigNumber(stats.totalQuantumVolume || 0),
'1e3'
)
: 0
}
label={t('Total volume')}
valueTestId="total-volume-stat"
/>
<Stat
className="flex flex-col-reverse"
value={
stats?.totalQuantumRewards
? formatNumberRounded(
new BigNumber(stats.totalQuantumRewards || 0),
'1e3'
)
: 0
}
label={t('Rewards paid out')}
valueTestId="rewards-paid-stat"
/>
</div>
<dl className="w-full pt-4 border-t border-vega-clight-700 dark:border-vega-cdark-700">
<dt className="mb-1 text-sm text-muted">
{t('Last {{games}} games result', {
replace: { games: lastGames.length || '' },
})}
</dt>
<dd className="flex flex-row flex-wrap gap-2">
{lastGames.length === 0 && t('None available')}
{lastGames.map((game, i) => (
<Tooltip key={i} description={DispatchMetricLabels[game.metric]}>
<button className="cursor-help text-sm bg-vega-clight-700 dark:bg-vega-cdark-700 px-2 py-1 rounded-full">
<RankLabel rank={game.rank} />
</button>
</Tooltip>
))}
</dd>
</dl>
</Box>
</div>
);
};
/**
* Sets the english ordinal for given rank only if the current language is set
* to english.
*/
const RankLabel = ({ rank }: { rank: number }) => {
const t = useT();
return t('place', { count: rank, ordinal: true });
};

View File

@ -15,6 +15,7 @@ import {
} from '../../lib/hooks/use-team'; } from '../../lib/hooks/use-team';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types'; import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types';
import classNames from 'classnames';
export const TeamStats = ({ export const TeamStats = ({
stats, stats,
@ -102,7 +103,13 @@ const LatestResults = ({ games }: { games: TeamGame[] }) => {
); );
}; };
const FavoriteGame = ({ games }: { games: TeamGame[] }) => { export const FavoriteGame = ({
games,
noLabel = false,
}: {
games: TeamGame[];
noLabel?: boolean;
}) => {
const t = useT(); const t = useT();
const rewardMetrics = games.map( const rewardMetrics = games.map(
@ -128,7 +135,13 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
return ( return (
<dl className="flex flex-col gap-1"> <dl className="flex flex-col gap-1">
<dt className="text-muted text-sm">{t('Favorite game')}</dt> <dt
className={classNames('text-muted text-sm', {
hidden: noLabel,
})}
>
{t('Favorite game')}
</dt>
<dd> <dd>
<Pill className="inline-flex items-center gap-1 bg-transparent text-sm"> <Pill className="inline-flex items-center gap-1 bg-transparent text-sm">
<VegaIcon <VegaIcon
@ -142,7 +155,7 @@ const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
); );
}; };
const StatSection = ({ children }: { children: ReactNode }) => { export const StatSection = ({ children }: { children: ReactNode }) => {
return ( return (
<section className="flex flex-col lg:flex-row gap-4 lg:gap-8"> <section className="flex flex-col lg:flex-row gap-4 lg:gap-8">
{children} {children}
@ -150,11 +163,11 @@ const StatSection = ({ children }: { children: ReactNode }) => {
); );
}; };
const StatSectionSeparator = () => { export const StatSectionSeparator = () => {
return <div className="hidden md:block border-r border-default" />; return <div className="hidden md:block border-r border-default" />;
}; };
const StatList = ({ children }: { children: ReactNode }) => { export const StatList = ({ children }: { children: ReactNode }) => {
return ( return (
<dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap"> <dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap">
{children} {children}
@ -162,19 +175,21 @@ const StatList = ({ children }: { children: ReactNode }) => {
); );
}; };
const Stat = ({ export const Stat = ({
value, value,
label, label,
tooltip, tooltip,
valueTestId, valueTestId,
className,
}: { }: {
value: ReactNode; value: ReactNode;
label: ReactNode; label: ReactNode;
tooltip?: string; tooltip?: string;
valueTestId?: string; valueTestId?: string;
className?: classNames.Argument;
}) => { }) => {
return ( return (
<div> <div className={classNames(className)}>
<dd className="text-3xl lg:text-4xl" data-testid={valueTestId}> <dd className="text-3xl lg:text-4xl" data-testid={valueTestId}>
{value} {value}
</dd> </dd>

View File

@ -152,7 +152,11 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
if (!enrichedTransfers || !enrichedTransfers.length) return null; if (!enrichedTransfers || !enrichedTransfers.length) return null;
return ( return (
<Card title={t('Active rewards')} className="lg:col-span-full"> <Card
title={t('Active rewards')}
className="lg:col-span-full"
data-testid="active-rewards-card"
>
{enrichedTransfers.length > 1 && ( {enrichedTransfers.length > 1 && (
<TradingInput <TradingInput
onChange={(e) => onChange={(e) =>
@ -312,49 +316,30 @@ export const ActiveRewardCard = ({
MarketState.STATE_CLOSED, MarketState.STATE_CLOSED,
].includes(m.state) ].includes(m.state)
); );
if (marketSettled) { if (marketSettled) {
return null; return null;
} }
const assetInSettledMarket = const assetInActiveMarket =
allMarkets && allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => { Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) { if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return ( return m?.state && MarketState.STATE_ACTIVE === m.state;
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
} }
return false; return false;
}); });
// Gray out the cards that are related to suspended markets const marketSuspended = transferNode.markets?.some(
const suspended = transferNode.markets?.some(
(m) => (m) =>
m?.state === MarketState.STATE_SUSPENDED || m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
); );
const assetInSuspendedMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
}
return false;
});
// Gray out the cards that are related to suspended markets // Gray out the cards that are related to suspended markets
// Or settlement assets in markets that are not active and eligible for rewards
const { gradientClassName, mainClassName } = const { gradientClassName, mainClassName } =
suspended || assetInSuspendedMarket || assetInSettledMarket marketSuspended || !assetInActiveMarket
? { ? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400', gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%', mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
@ -371,6 +356,7 @@ export const ActiveRewardCard = ({
'rounded-lg', 'rounded-lg',
gradientClassName gradientClassName
)} )}
data-testid="active-rewards-card"
> >
<div <div
className={classNames( className={classNames(
@ -382,7 +368,7 @@ export const ActiveRewardCard = ({
<div className="flex flex-col gap-2 items-center text-center"> <div className="flex flex-col gap-2 items-center text-center">
<EntityIcon transfer={transfer} /> <EntityIcon transfer={transfer} />
{entityScope && ( {entityScope && (
<span className="text-muted text-xs"> <span className="text-muted text-xs" data-testid="entity-scope">
{EntityScopeLabelMapping[entityScope] || t('Unspecified')} {EntityScopeLabelMapping[entityScope] || t('Unspecified')}
</span> </span>
)} )}
@ -390,7 +376,7 @@ export const ActiveRewardCard = ({
<div className="flex flex-col gap-2 items-center text-center"> <div className="flex flex-col gap-2 items-center text-center">
<h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center"> <h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center">
<span className="font-glitch"> <span className="font-glitch" data-testid="reward-value">
{addDecimalsFormatNumber( {addDecimalsFormatNumber(
transferNode.transfer.amount, transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0, transferNode.transfer.asset?.decimals || 0,
@ -411,7 +397,7 @@ export const ActiveRewardCard = ({
)} )}
underline={true} underline={true}
> >
<span className="text-xs"> <span className="text-xs" data-testid="distribution-strategy">
{ {
DistributionStrategyMapping[ DistributionStrategyMapping[
dispatchStrategy.distributionStrategy dispatchStrategy.distributionStrategy
@ -429,7 +415,10 @@ export const ActiveRewardCard = ({
'Number of epochs after distribution to delay vesting of rewards by' 'Number of epochs after distribution to delay vesting of rewards by'
)} )}
/> />
<span className="text-muted text-xs whitespace-nowrap"> <span
className="text-muted text-xs whitespace-nowrap"
data-testid="locked-for"
>
{t('numberEpochs', '{{count}} epochs', { {t('numberEpochs', '{{count}} epochs', {
count: kind.dispatchStrategy?.lockPeriod, count: kind.dispatchStrategy?.lockPeriod,
})} })}
@ -438,15 +427,15 @@ export const ActiveRewardCard = ({
</div> </div>
<span className="border-[0.5px] border-gray-700" /> <span className="border-[0.5px] border-gray-700" />
<span> <span data-testid="dispatch-metric-info">
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '} {DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '}
<Tooltip <Tooltip
underline={suspended} underline={marketSuspended}
description={ description={
(suspended || assetInSuspendedMarket) && (marketSuspended || !assetInActiveMarket) &&
(specificMarkets (specificMarkets
? t('Eligible market(s) currently suspended') ? t('Eligible market(s) currently suspended')
: assetInSuspendedMarket : !assetInActiveMarket
? t('Currently no markets eligible for reward') ? t('Currently no markets eligible for reward')
: '') : '')
} }
@ -458,8 +447,8 @@ export const ActiveRewardCard = ({
<div className="flex items-center gap-8 flex-wrap"> <div className="flex items-center gap-8 flex-wrap">
{kind.endEpoch && ( {kind.endEpoch && (
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-muted text-xs">{t('Ends in')}</span> <span className="text-muted text-xs">{t('Ends in')} </span>
<span> <span data-testid="ends-in">
{t('numberEpochs', '{{count}} epochs', { {t('numberEpochs', '{{count}} epochs', {
count: kind.endEpoch - currentEpoch, count: kind.endEpoch - currentEpoch,
})} })}
@ -470,7 +459,7 @@ export const ActiveRewardCard = ({
{ {
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-muted text-xs">{t('Assessed over')}</span> <span className="text-muted text-xs">{t('Assessed over')}</span>
<span> <span data-testid="assessed-over">
{t('numberEpochs', '{{count}} epochs', { {t('numberEpochs', '{{count}} epochs', {
count: dispatchStrategy.windowLength, count: dispatchStrategy.windowLength,
})} })}
@ -513,7 +502,7 @@ const RewardRequirements = ({
entity: EntityScopeLabelMapping[dispatchStrategy.entityScope], entity: EntityScopeLabelMapping[dispatchStrategy.entityScope],
})} })}
</dt> </dt>
<dd className="flex items-center gap-1"> <dd className="flex items-center gap-1" data-testid="scope">
<RewardEntityScope dispatchStrategy={dispatchStrategy} /> <RewardEntityScope dispatchStrategy={dispatchStrategy} />
</dd> </dd>
</div> </div>
@ -522,7 +511,10 @@ const RewardRequirements = ({
<dt className="flex items-center gap-1 text-muted"> <dt className="flex items-center gap-1 text-muted">
{t('Staked VEGA')} {t('Staked VEGA')}
</dt> </dt>
<dd className="flex items-center gap-1"> <dd
className="flex items-center gap-1"
data-testid="staking-requirement"
>
{addDecimalsFormatNumber( {addDecimalsFormatNumber(
dispatchStrategy?.stakingRequirement || 0, dispatchStrategy?.stakingRequirement || 0,
assetDecimalPlaces assetDecimalPlaces
@ -534,7 +526,7 @@ const RewardRequirements = ({
<dt className="flex items-center gap-1 text-muted"> <dt className="flex items-center gap-1 text-muted">
{t('Average position')} {t('Average position')}
</dt> </dt>
<dd className="flex items-center gap-1"> <dd className="flex items-center gap-1" data-testid="average-position">
{addDecimalsFormatNumber( {addDecimalsFormatNumber(
dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement || dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement ||
0, 0,

View File

@ -33,7 +33,7 @@ def test_should_display_info_and_button_for_deposit(continuous_market, page: Pag
"You may not have enough margin available to open this position.") "You may not have enough margin available to open this position.")
page.get_by_test_id(deal_ticket_warning_margin).hover() page.get_by_test_id(deal_ticket_warning_margin).hover()
expect(page.get_by_test_id("tooltip-content").nth(0)).to_have_text( expect(page.get_by_test_id("tooltip-content").nth(0)).to_have_text(
"1,661,896.6317 tDAI is currently required.You have only 1,000,000.00.Deposit tDAI") "1,661,888.12901 tDAI is currently required.You have only 999,991.49731.Deposit tDAI")
page.get_by_test_id(deal_ticket_deposit_dialog_button).nth(0).click() page.get_by_test_id(deal_ticket_deposit_dialog_button).nth(0).click()
expect(page.get_by_test_id("sidebar-content") expect(page.get_by_test_id("sidebar-content")
).to_contain_text("DepositFrom") ).to_contain_text("DepositFrom")

View File

@ -59,7 +59,7 @@ def test_filtered_cards(continuous_market, vega: VegaServiceNull, page: Page):
next_epoch(vega=vega) next_epoch(vega=vega)
page.reload() page.reload()
expect(page.locator(".from-vega-cdark-400")).to_be_visible(timeout=15000) expect(page.get_by_test_id("active-rewards-card")).to_be_visible(timeout=15000)
governance.submit_oracle_data( governance.submit_oracle_data(
wallet=vega.wallet, wallet=vega.wallet,
payload={"trading.terminated": "true"}, payload={"trading.terminated": "true"},
@ -67,4 +67,4 @@ def test_filtered_cards(continuous_market, vega: VegaServiceNull, page: Page):
) )
next_epoch(vega=vega) next_epoch(vega=vega)
page.reload() page.reload()
expect(page.locator(".from-vega-cdark-400")).not_to_be_in_viewport() expect(page.get_by_test_id("active-rewards-card")).not_to_be_in_viewport()

View File

@ -3,7 +3,7 @@ from playwright.sync_api import expect, Page
import vega_sim.proto.vega as vega_protos import vega_sim.proto.vega as vega_protos
from vega_sim.null_service import VegaServiceNull from vega_sim.null_service import VegaServiceNull
from conftest import init_vega from conftest import init_vega
from actions.utils import next_epoch from actions.utils import next_epoch, change_keys
from fixtures.market import setup_continuous_market from fixtures.market import setup_continuous_market
from conftest import auth_setup, init_page, init_vega, risk_accepted_setup from conftest import auth_setup, init_page, init_vega, risk_accepted_setup
from wallet_config import PARTY_A, PARTY_B, PARTY_C, PARTY_D, MM_WALLET from wallet_config import PARTY_A, PARTY_B, PARTY_C, PARTY_D, MM_WALLET
@ -14,6 +14,7 @@ def vega(request):
with init_vega(request) as vega: with init_vega(request) as vega:
yield vega yield vega
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def team_page(vega, browser, request, setup_teams_and_games): def team_page(vega, browser, request, setup_teams_and_games):
with init_page(vega, browser, request) as page: with init_page(vega, browser, request) as page:
@ -23,9 +24,20 @@ def team_page(vega, browser, request, setup_teams_and_games):
page.goto(f"/#/competitions/teams/{team_id}") page.goto(f"/#/competitions/teams/{team_id}")
yield page yield page
@pytest.fixture(scope="module")
def competitions_page(vega, browser, request, setup_teams_and_games):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
team_id = setup_teams_and_games["team_id"]
page.goto(f"/#/competitions/")
yield page
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def setup_teams_and_games(vega: VegaServiceNull): def setup_teams_and_games(vega: VegaServiceNull):
tDAI_market = setup_continuous_market(vega) tDAI_market = setup_continuous_market(vega, custom_quantum=100000)
tDAI_asset_id = vega.find_asset_id(symbol="tDAI") tDAI_asset_id = vega.find_asset_id(symbol="tDAI")
vega.mint(key_name=PARTY_B.name, asset=tDAI_asset_id, amount=100000) vega.mint(key_name=PARTY_B.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_C.name, asset=tDAI_asset_id, amount=100000) vega.mint(key_name=PARTY_C.name, asset=tDAI_asset_id, amount=100000)
@ -46,6 +58,18 @@ def setup_teams_and_games(vega: VegaServiceNull):
# list_teams actually returns a dictionary {"team_id": Team} # list_teams actually returns a dictionary {"team_id": Team}
team_id = list(teams.keys())[0] team_id = list(teams.keys())[0]
vega.create_referral_set(
key_name="market_maker",
name="test",
team_url="https://vega.xyz",
avatar_url="http://placekitten.com/200/200",
closed=False,
)
next_epoch(vega)
teams = vega.list_teams()
team_id_2 = list(teams.keys())[0]
vega.apply_referral_code("Key 1", team_id_2)
vega.apply_referral_code(PARTY_B.name, team_id) vega.apply_referral_code(PARTY_B.name, team_id)
@ -63,7 +87,7 @@ def setup_teams_and_games(vega: VegaServiceNull):
current_epoch = vega.statistics().epoch_seq current_epoch = vega.statistics().epoch_seq
game_start = current_epoch + 1 game_start = current_epoch + 1
game_end = current_epoch + 11 game_end = current_epoch + 14
current_epoch = vega.statistics().epoch_seq current_epoch = vega.statistics().epoch_seq
print(f"[EPOCH: {current_epoch}] creating recurring transfer") print(f"[EPOCH: {current_epoch}] creating recurring transfer")
@ -84,9 +108,42 @@ def setup_teams_and_games(vega: VegaServiceNull):
factor=1.0, factor=1.0,
start_epoch=game_start, start_epoch=game_start,
end_epoch=game_end, end_epoch=game_end,
window_length=10 window_length=15,
)
vega.wait_fn(1)
vega.wait_for_total_catchup()
vega.recurring_transfer(
from_key_name=PARTY_B.name,
from_account_type=vega_protos.vega.ACCOUNT_TYPE_GENERAL,
to_account_type=vega_protos.vega.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES,
asset=tDAI_asset_id,
reference="reward",
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
entity_scope=vega_protos.vega.ENTITY_SCOPE_INDIVIDUALS,
individual_scope=vega_protos.vega.INDIVIDUAL_SCOPE_IN_TEAM,
n_top_performers=1,
amount=100,
factor=1.0,
window_length=15
)
vega.wait_fn(1)
vega.wait_for_total_catchup()
vega.recurring_transfer(
from_key_name=PARTY_C.name,
from_account_type=vega_protos.vega.ACCOUNT_TYPE_GENERAL,
to_account_type=vega_protos.vega.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES,
asset=tDAI_asset_id,
reference="reward",
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
entity_scope=vega_protos.vega.ENTITY_SCOPE_INDIVIDUALS,
individual_scope=vega_protos.vega.INDIVIDUAL_SCOPE_NOT_IN_TEAM,
n_top_performers=1,
amount=100,
factor=1.0,
window_length=15
) )
next_epoch(vega) next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] starting order activity") print(f"[EPOCH: {vega.statistics().epoch_seq}] starting order activity")
@ -113,6 +170,22 @@ def setup_teams_and_games(vega: VegaServiceNull):
side="SIDE_BUY", side="SIDE_BUY",
volume=1, volume=1,
) )
vega.submit_order(
trading_key="Key 1",
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
vega.submit_order(
trading_key="market_maker",
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
next_epoch(vega) next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] {i} epoch passed") print(f"[EPOCH: {vega.statistics().epoch_seq}] {i} epoch passed")
@ -120,6 +193,7 @@ def setup_teams_and_games(vega: VegaServiceNull):
"market_id": tDAI_market, "market_id": tDAI_market,
"asset_id": tDAI_asset_id, "asset_id": tDAI_asset_id,
"team_id": team_id, "team_id": team_id,
"team_id_2": team_id_2,
"team_name": team_name, "team_name": team_name,
} }
@ -136,66 +210,109 @@ def create_team(vega: VegaServiceNull):
return team_name return team_name
def test_team_page_games_table(team_page: Page): def test_team_page_games_table(team_page: Page):
team_page.pause()
team_page.get_by_test_id("games-toggle").click() team_page.get_by_test_id("games-toggle").click()
expect(team_page.get_by_test_id("games-toggle")).to_have_text("Games (1)") expect(team_page.get_by_test_id("games-toggle")).to_have_text("Games (1)")
expect(team_page.get_by_test_id("rank-0")).to_have_text("1") expect(team_page.get_by_test_id("rank-0")).to_have_text("2")
expect(team_page.get_by_test_id("epoch-0")).to_have_text("18") expect(team_page.get_by_test_id("epoch-0")).to_have_text("19")
expect(team_page.get_by_test_id("type-0")).to_have_text("Price maker fees paid") expect(team_page.get_by_test_id("type-0")
expect(team_page.get_by_test_id("amount-0")).to_have_text("100,000,000") ).to_have_text("Price maker fees paid")
expect(team_page.get_by_test_id("participatingTeams-0")).to_have_text( expect(team_page.get_by_test_id("amount-0")).to_have_text("74")
"1" expect(team_page.get_by_test_id("participatingTeams-0")).to_have_text("2")
) expect(team_page.get_by_test_id("participatingMembers-0")).to_have_text("4")
expect(team_page.get_by_test_id("participatingMembers-0")).to_have_text(
"2"
)
def test_team_page_members_table(team_page: Page): def test_team_page_members_table(team_page: Page):
team_page.get_by_test_id("members-toggle").click() team_page.get_by_test_id("members-toggle").click()
expect(team_page.get_by_test_id("members-toggle")).to_have_text("Members (4)") expect(team_page.get_by_test_id("members-toggle")
).to_have_text("Members (4)")
expect(team_page.get_by_test_id("referee-0")).to_be_visible() expect(team_page.get_by_test_id("referee-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAt-0")).to_be_visible() expect(team_page.get_by_test_id("joinedAt-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAtEpoch-0")).to_have_text("8") expect(team_page.get_by_test_id("joinedAtEpoch-0")).to_have_text("9")
def test_team_page_headline(team_page: Page, setup_teams_and_games
): def test_team_page_headline(team_page: Page, setup_teams_and_games):
team_name = setup_teams_and_games["team_name"] team_name = setup_teams_and_games["team_name"]
expect(team_page.get_by_test_id("team-name")).to_have_text(team_name) expect(team_page.get_by_test_id("team-name")).to_have_text(team_name)
expect(team_page.get_by_test_id("members-count-stat")).to_have_text("4") expect(team_page.get_by_test_id("members-count-stat")).to_have_text("4")
expect(team_page.get_by_test_id("total-games-stat")).to_have_text( expect(team_page.get_by_test_id("total-games-stat")).to_have_text("2")
"1"
)
# TODO this still seems wrong as its always 0 # TODO this still seems wrong as its always 0
expect(team_page.get_by_test_id("total-volume-stat")).to_have_text( expect(team_page.get_by_test_id("total-volume-stat")).to_have_text("0")
"0"
)
expect(team_page.get_by_test_id("rewards-paid-stat")).to_have_text( expect(team_page.get_by_test_id("rewards-paid-stat")).to_have_text("214")
"100m"
)
def test_switch_teams(team_page: Page, vega: VegaServiceNull):
team_page.get_by_test_id("switch-team-button").click()
team_page.get_by_test_id("confirm-switch-button").click()
expect(team_page.get_by_test_id("dialog-content").first).to_be_visible()
vega.wait_fn(1)
vega.wait_for_total_catchup()
next_epoch(vega=vega)
team_page.reload()
expect(team_page.get_by_test_id("members-count-stat")).to_have_text("5")
@pytest.fixture(scope="module")
def competitions_page(vega, browser, request):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
yield page
def test_leaderboard(competitions_page: Page, setup_teams_and_games): def test_leaderboard(competitions_page: Page, setup_teams_and_games):
team_name = setup_teams_and_games["team_name"] team_name = setup_teams_and_games["team_name"]
competitions_page.goto(f"/#/competitions/") competitions_page.reload()
expect(competitions_page.get_by_test_id("rank-0").locator(".text-yellow-300")).to_have_count(1) expect(
expect(competitions_page.get_by_test_id("team-0")).to_have_text(team_name) competitions_page.get_by_test_id("rank-0").locator(".text-yellow-300")
expect(competitions_page.get_by_test_id("status-0")).to_have_text("Open") ).to_have_count(1)
expect(
competitions_page.get_by_test_id(
"rank-1").locator(".text-vega-clight-500")
).to_have_count(1)
expect(competitions_page.get_by_test_id("team-1")).to_have_text(team_name)
expect(competitions_page.get_by_test_id("status-1")).to_have_text("Open")
expect(competitions_page.get_by_test_id("earned-0")).to_have_text("100,000,000") # FIXME: the numbers are different we need to clarify this with the backend
expect(competitions_page.get_by_test_id("games-0")).to_have_text("1") # expect(competitions_page.get_by_test_id("earned-1")).to_have_text("160")
expect(competitions_page.get_by_test_id("games-1")).to_have_text("2")
# TODO still odd that this is 0 # TODO still odd that this is 0
expect(competitions_page.get_by_test_id("volume-0")).to_have_text("-") expect(competitions_page.get_by_test_id("volume-0")).to_have_text("-")
#TODO def test_games(competitions_page: Page):
#TODO currently no games appear which i think is a bug def test_game_card(competitions_page: Page):
expect(competitions_page.get_by_test_id(
"active-rewards-card")).to_have_count(2)
game_1 = competitions_page.get_by_test_id("active-rewards-card").first
expect(game_1).to_be_visible()
expect(game_1.get_by_test_id("entity-scope")).to_have_text("Individual")
expect(game_1.get_by_test_id("locked-for")).to_have_text("1 epoch")
expect(game_1.get_by_test_id("reward-value")).to_have_text("100.00")
expect(game_1.get_by_test_id("distribution-strategy")
).to_have_text("Pro rata")
expect(game_1.get_by_test_id("dispatch-metric-info")
).to_have_text("Price maker fees paid • ")
expect(game_1.get_by_test_id("assessed-over")).to_have_text("15 epochs")
expect(game_1.get_by_test_id("scope")).to_have_text("In team")
expect(game_1.get_by_test_id("staking-requirement")).to_have_text("0.00")
expect(game_1.get_by_test_id("average-position")).to_have_text("0.00")
def test_create_team(competitions_page: Page, vega: VegaServiceNull):
change_keys(competitions_page, vega, "market_maker_2")
competitions_page.get_by_test_id("create-public-team-button").click()
competitions_page.get_by_test_id("team-name-input").fill("e2e")
competitions_page.get_by_test_id("team-url-input").fill("https://vega.xyz")
competitions_page.get_by_test_id("avatar-url-input").fill(
"http://placekitten.com/200/200"
)
competitions_page.get_by_test_id("team-form-submit-button").click()
expect(competitions_page.get_by_test_id("team-form-submit-button")).to_have_text(
"Confirming transaction..."
)
vega.wait_fn(2)
vega.wait_for_total_catchup()
expect(
competitions_page.get_by_test_id("team-creation-success-message")
).to_be_visible()
expect(competitions_page.get_by_test_id("team-id-display")).to_be_visible()
expect(competitions_page.get_by_test_id("team-id-display")).to_be_visible()
competitions_page.get_by_test_id("view-team-button").click()
expect(competitions_page.get_by_test_id("team-name")).to_have_text("e2e")

View File

@ -1,15 +1,20 @@
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
query Teams($teamId: ID, $partyId: ID) { query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) { teams(teamId: $teamId, partyId: $partyId) {
edges { edges {
node { node {
teamId ...TeamsFields
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
} }
} }
} }

View File

@ -3,33 +3,40 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client'; import * as Apollo from '@apollo/client';
const defaultOptions = {} as const; const defaultOptions = {} as const;
export type TeamsFieldsFragment = { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, totalMembers: number };
export type TeamsQueryVariables = Types.Exact<{ export type TeamsQueryVariables = Types.Exact<{
teamId?: Types.InputMaybe<Types.Scalars['ID']>; teamId?: Types.InputMaybe<Types.Scalars['ID']>;
partyId?: Types.InputMaybe<Types.Scalars['ID']>; partyId?: Types.InputMaybe<Types.Scalars['ID']>;
}>; }>;
export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean } }> } | null }; export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, totalMembers: number } }> } | null };
export const TeamsFieldsFragmentDoc = gql`
fragment TeamsFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
totalMembers
}
`;
export const TeamsDocument = gql` export const TeamsDocument = gql`
query Teams($teamId: ID, $partyId: ID) { query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) { teams(teamId: $teamId, partyId: $partyId) {
edges { edges {
node { node {
teamId ...TeamsFields
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
} }
} }
} }
} }
`; ${TeamsFieldsFragmentDoc}`;
/** /**
* __useTeamsQuery__ * __useTeamsQuery__

View File

@ -1,12 +1,23 @@
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import { useActiveRewardsQuery } from '../../components/rewards-container/__generated__/Rewards'; import { useActiveRewardsQuery } from '../../components/rewards-container/__generated__/Rewards';
import { isActiveReward } from '../../components/rewards-container/active-rewards'; import { isActiveReward } from '../../components/rewards-container/active-rewards';
import { EntityScope, type TransferNode } from '@vegaprotocol/types'; import {
EntityScope,
IndividualScope,
type TransferNode,
} from '@vegaprotocol/types';
const isScopedToTeams = (node: TransferNode) => const isScopedToTeams = (node: TransferNode) =>
node.transfer.kind.__typename === 'RecurringTransfer' && node.transfer.kind.__typename === 'RecurringTransfer' &&
node.transfer.kind.dispatchStrategy?.entityScope === // scoped to teams
EntityScope.ENTITY_SCOPE_TEAMS; (node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS ||
// or to individuals
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
// but they have to be in a team
node.transfer.kind.dispatchStrategy.individualScope ===
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM));
export const useGames = ({ export const useGames = ({
currentEpoch, currentEpoch,

View File

@ -0,0 +1,25 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import compact from 'lodash/compact';
import first from 'lodash/first';
import { useTeamsQuery } from './__generated__/Teams';
import { useTeam } from './use-team';
import { useTeams } from './use-teams';
export const useMyTeam = () => {
const { pubKey } = useVegaWallet();
const { data: teams } = useTeams();
const { data: maybeMyTeam } = useTeamsQuery({
variables: {
partyId: pubKey,
},
skip: !pubKey,
fetchPolicy: 'cache-and-network',
});
const team = first(compact(maybeMyTeam?.teams?.edges.map((n) => n.node)));
const rank = teams.findIndex((t) => t.teamId === team?.teamId) + 1;
const { games, stats } = useTeam(team?.teamId);
return { team, stats, games, rank };
};

View File

@ -4,7 +4,8 @@ import { useTeamsQuery } from './__generated__/Teams';
import { useTeamsStatisticsQuery } from './__generated__/TeamsStatistics'; import { useTeamsStatisticsQuery } from './__generated__/TeamsStatistics';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
export const DEFAULT_AGGREGATION_EPOCHS = 10; // 192
export const DEFAULT_AGGREGATION_EPOCHS = 192;
export const useTeams = (aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS) => { export const useTeams = (aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS) => {
const { const {

View File

@ -3,27 +3,13 @@ import { getAsset, getQuoteName } from '@vegaprotocol/markets';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountBreakdownDialog } from '@vegaprotocol/accounts'; import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
import { formatRange, formatValue } from '@vegaprotocol/utils'; import { formatRange, formatValue } from '@vegaprotocol/utils';
import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { import {
MARGIN_DIFF_TOOLTIP_TEXT,
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
TOTAL_MARGIN_AVAILABLE,
LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT, LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT,
EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants'; } from '../../constants';
import { KeyValue } from './key-value'; import { KeyValue } from './key-value';
import { import { ExternalLink } from '@vegaprotocol/ui-toolkit';
Accordion,
AccordionChevron,
AccordionPanel,
ExternalLink,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { useT, ns } from '../../use-t'; import { useT, ns } from '../../use-t';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import type { Market } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets';
@ -31,9 +17,9 @@ import { emptyValue } from './deal-ticket-fee-details';
import type { EstimatePositionQuery } from '@vegaprotocol/positions'; import type { EstimatePositionQuery } from '@vegaprotocol/positions';
export interface DealTicketMarginDetailsProps { export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string; generalAccountBalance: string;
marginAccountBalance?: string; marginAccountBalance: string;
orderMarginAccountBalance?: string; orderMarginAccountBalance: string;
market: Market; market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string; assetSymbol: string;
@ -54,25 +40,13 @@ export const DealTicketMarginDetails = ({
const t = useT(); const t = useT();
const [breakdownDialog, setBreakdownDialog] = useState(false); const [breakdownDialog, setBreakdownDialog] = useState(false);
const { pubKey: partyId } = useVegaWallet(); const { pubKey: partyId } = useVegaWallet();
const { data: currentMargins } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: { marketId: market.id, partyId: partyId || '' },
skip: !partyId,
});
const isInIsolatedMode =
positionEstimate?.margin.bestCase.marginMode ===
Schema.MarginMode.MARGIN_MODE_ISOLATED_MARGIN;
const liquidationEstimate = positionEstimate?.liquidation; const liquidationEstimate = positionEstimate?.liquidation;
const marginEstimate = positionEstimate?.margin;
const totalMarginAccountBalance = const totalMarginAccountBalance =
BigInt(marginAccountBalance || '0') + BigInt(marginAccountBalance || '0') +
BigInt(orderMarginAccountBalance || '0'); BigInt(orderMarginAccountBalance || '0');
const totalBalance =
BigInt(generalAccountBalance || '0') + totalMarginAccountBalance;
const asset = getAsset(market); const asset = getAsset(market);
const { decimals: assetDecimals, quantum } = asset; const { decimals: assetDecimals, quantum } = asset;
let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined;
const collateralIncreaseEstimateBestCase = BigInt( const collateralIncreaseEstimateBestCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.bestCase ?? '0' positionEstimate?.collateralIncreaseEstimate.bestCase ?? '0'
@ -80,102 +54,6 @@ export const DealTicketMarginDetails = ({
const collateralIncreaseEstimateWorstCase = BigInt( const collateralIncreaseEstimateWorstCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.worstCase ?? '0' positionEstimate?.collateralIncreaseEstimate.worstCase ?? '0'
); );
const marginEstimateBestCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateBestCase
: BigInt(marginEstimate?.bestCase.initialLevel ?? 0);
const marginEstimateWorstCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateWorstCase
: BigInt(marginEstimate?.worstCase.initialLevel ?? 0);
if (isInIsolatedMode) {
marginRequiredBestCase = collateralIncreaseEstimateBestCase.toString();
marginRequiredWorstCase = collateralIncreaseEstimateWorstCase.toString();
} else if (marginEstimate) {
if (currentMargins) {
const currentMargin = BigInt(currentMargins.initialLevel);
marginRequiredBestCase = (
marginEstimateBestCase - currentMargin
).toString();
if (marginRequiredBestCase.startsWith('-')) {
marginRequiredBestCase = '0';
}
marginRequiredWorstCase = (
marginEstimateWorstCase - currentMargin
).toString();
if (marginRequiredWorstCase.startsWith('-')) {
marginRequiredWorstCase = '0';
}
} else {
marginRequiredBestCase = marginEstimateBestCase.toString();
marginRequiredWorstCase = marginEstimateWorstCase.toString();
}
}
const totalMarginAvailable = (
currentMargins
? totalBalance - BigInt(currentMargins.maintenanceLevel)
: totalBalance
).toString();
let deductionFromCollateral = null;
let projectedMargin = null;
if (totalMarginAccountBalance) {
const deductionFromCollateralBestCase =
marginEstimateBestCase - totalMarginAccountBalance;
const deductionFromCollateralWorstCase =
marginEstimateWorstCase - totalMarginAccountBalance;
deductionFromCollateral = (
<KeyValue
indent
label={t('Deduction from collateral')}
value={formatRange(
deductionFromCollateralBestCase > 0
? deductionFromCollateralBestCase.toString()
: '0',
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals
)}
formattedValue={formatValue(
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT',
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
{ assetSymbol }
)}
/>
);
projectedMargin = (
<KeyValue
label={t('Projected margin')}
value={formatRange(
marginEstimateBestCase.toString(),
marginEstimateWorstCase.toString(),
assetDecimals
)}
formattedValue={formatValue(
marginEstimateWorstCase.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'EST_TOTAL_MARGIN_TOOLTIP_TEXT',
EST_TOTAL_MARGIN_TOOLTIP_TEXT
)}
/>
);
}
let liquidationPriceEstimate = emptyValue; let liquidationPriceEstimate = emptyValue;
let liquidationPriceEstimateRange = emptyValue; let liquidationPriceEstimateRange = emptyValue;
@ -232,128 +110,50 @@ export const DealTicketMarginDetails = ({
const quoteName = getQuoteName(market); const quoteName = getQuoteName(market);
return ( return (
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2 mt-2">
<Accordion>
<AccordionPanel
itemId="margin"
trigger={
<AccordionPrimitive.Trigger
data-testid="accordion-toggle"
className={classNames(
'w-full pt-2',
'flex items-center gap-2 text-xs',
'group'
)}
>
<div
data-testid={`deal-ticket-fee-margin-required`}
key={'value-dropdown'}
className="flex items-center justify-between w-full gap-2"
>
<div className="flex items-center text-left gap-1">
<Tooltip
description={t(
'MARGIN_DIFF_TOOLTIP_TEXT',
MARGIN_DIFF_TOOLTIP_TEXT,
{ assetSymbol }
)}
>
<span className="text-muted">{t('Margin required')}</span>
</Tooltip>
<AccordionChevron size={10} />
</div>
<Tooltip
description={
formatRange(
marginRequiredBestCase,
marginRequiredWorstCase,
assetDecimals
) ?? '-'
}
>
<div className="font-mono text-right">
{formatValue(
marginRequiredWorstCase,
assetDecimals,
quantum
)}{' '}
{assetSymbol || ''}
</div>
</Tooltip>
</div>
</AccordionPrimitive.Trigger>
}
>
<div className="flex flex-col w-full gap-2">
<KeyValue
label={t('Total margin available')}
indent
value={formatValue(totalMarginAvailable, assetDecimals)}
formattedValue={formatValue(
totalMarginAvailable,
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'TOTAL_MARGIN_AVAILABLE',
TOTAL_MARGIN_AVAILABLE,
{
generalAccountBalance: formatValue(
generalAccountBalance,
assetDecimals,
quantum
),
marginAccountBalance: formatValue(
marginAccountBalance,
assetDecimals,
quantum
),
orderMarginAccountBalance: formatValue(
orderMarginAccountBalance,
assetDecimals,
quantum
),
marginMaintenance: formatValue(
currentMargins?.maintenanceLevel,
assetDecimals,
quantum
),
assetSymbol,
}
)}
/>
{deductionFromCollateral}
<KeyValue
label={t('Current margin allocation')}
indent
onClick={
generalAccountBalance
? () => setBreakdownDialog(true)
: undefined
}
value={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals
)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals,
quantum
)}
/>
</div>
</AccordionPanel>
</Accordion>
{projectedMargin}
<KeyValue <KeyValue
label={t('Liquidation')} label={t('Current margin')}
onClick={
generalAccountBalance ? () => setBreakdownDialog(true) : undefined
}
value={formatValue(totalMarginAccountBalance.toString(), assetDecimals)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals,
quantum
)}
/>
<KeyValue
label={t('Available collateral')}
value={formatValue(generalAccountBalance, assetDecimals)}
formattedValue={formatValue(
generalAccountBalance.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
/>
<KeyValue
label={t('Additional margin required')}
value={formatRange(
collateralIncreaseEstimateBestCase.toString(),
collateralIncreaseEstimateWorstCase.toString(),
assetDecimals
)}
formattedValue={formatValue(
collateralIncreaseEstimateBestCase.toString(),
assetDecimals,
quantum
)}
symbol={assetSymbol}
/>
<KeyValue
label={t('Liquidation estimate')}
value={liquidationPriceEstimateRange} value={liquidationPriceEstimateRange}
formattedValue={liquidationPriceEstimate} formattedValue={liquidationPriceEstimate}
symbol={quoteName} symbol={quoteName}

View File

@ -73,7 +73,7 @@ import {
} from '../../hooks'; } from '../../hooks';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop'; import noop from 'lodash/noop';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistance'; import { isNonPersistentOrder } from '../../utils/time-in-force-persistence';
import { KeyValue } from './key-value'; import { KeyValue } from './key-value';
import { DocsLinks } from '@vegaprotocol/environment'; import { DocsLinks } from '@vegaprotocol/environment';
import { useT } from '../../use-t'; import { useT } from '../../use-t';
@ -177,12 +177,6 @@ export const DealTicket = ({
loading: loadingGeneralAccountBalance, loading: loadingGeneralAccountBalance,
} = useAccountBalance(asset.id); } = useAccountBalance(asset.id);
const balance = (
BigInt(marginAccountBalance) +
BigInt(generalAccountBalance) +
BigInt(orderMarginAccountBalance)
).toString();
const { marketState, marketTradingMode } = marketData; const { marketState, marketTradingMode } = marketData;
const timeInForce = watch('timeInForce'); const timeInForce = watch('timeInForce');
@ -729,17 +723,11 @@ export const DealTicket = ({
error={summaryError} error={summaryError}
asset={asset} asset={asset}
marketTradingMode={marketData.marketTradingMode} marketTradingMode={marketData.marketTradingMode}
balance={balance} balance={generalAccountBalance}
margin={( margin={
BigInt( positionEstimate?.estimatePosition?.collateralIncreaseEstimate
positionEstimate?.estimatePosition?.margin.bestCase.initialLevel || .bestCase || '0'
'0' }
) +
BigInt(
positionEstimate?.estimatePosition?.margin.bestCase
.orderMarginLevel || '0'
)
).toString()}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
pubKey={pubKey} pubKey={pubKey}
onDeposit={onDeposit} onDeposit={onDeposit}

View File

@ -1,5 +1,4 @@
import { Tooltip } from '@vegaprotocol/ui-toolkit'; import { Tooltip } from '@vegaprotocol/ui-toolkit';
import classnames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export interface KeyValuePros { export interface KeyValuePros {
@ -19,7 +18,6 @@ export const KeyValue = ({
value, value,
labelDescription, labelDescription,
symbol, symbol,
indent,
onClick, onClick,
formattedValue, formattedValue,
}: KeyValuePros) => { }: KeyValuePros) => {
@ -43,10 +41,7 @@ export const KeyValue = ({
: id : id
}`} }`}
key={typeof label === 'string' ? label : 'value-dropdown'} key={typeof label === 'string' ? label : 'value-dropdown'}
className={classnames( className="text-xs flex justify-between items-center gap-4 flex-wrap text-right"
'text-xs flex justify-between items-center gap-4 flex-wrap text-right',
{ 'ml-2': indent }
)}
> >
<Tooltip description={labelDescription}> <Tooltip description={labelDescription}>
<div className="text-muted text-left">{label}</div> <div className="text-muted text-left">{label}</div>

View File

@ -29,6 +29,7 @@ import { usePositionEstimate } from '../../hooks/use-position-estimate';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { getAsset, useMarket } from '@vegaprotocol/markets'; import { getAsset, useMarket } from '@vegaprotocol/markets';
import { NoWalletWarning } from './deal-ticket'; import { NoWalletWarning } from './deal-ticket';
import { DealTicketMarginDetails } from './deal-ticket-margin-details';
const defaultLeverage = 10; const defaultLeverage = 10;
@ -93,66 +94,78 @@ export const MarginChange = ({
}, },
skip skip
); );
if ( if (!asset || !estimateMargin?.estimatePosition) {
!asset ||
!estimateMargin?.estimatePosition?.collateralIncreaseEstimate.worstCase ||
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase === '0'
) {
return null; return null;
} }
const collateralIncreaseEstimate = BigInt( const collateralIncreaseEstimate = BigInt(
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase
); );
if (!collateralIncreaseEstimate) {
return null;
}
let positionWarning = ''; let positionWarning = '';
if (orders?.length && openVolume !== '0') {
positionWarning = t(
'youHaveOpenPositionAndOrders',
'You have an existing position and open orders on this market.',
{
count: orders.length,
}
);
} else if (!orders?.length) {
positionWarning = t('You have an existing position on this market.');
} else {
positionWarning = t(
'youHaveOpenOrders',
'You have open orders on this market.',
{
count: orders.length,
}
);
}
let marginChangeWarning = ''; let marginChangeWarning = '';
const amount = addDecimalsFormatNumber( if (collateralIncreaseEstimate) {
collateralIncreaseEstimate.toString(), if (orders?.length && openVolume !== '0') {
asset?.decimals positionWarning = t(
); 'youHaveOpenPositionAndOrders',
const { symbol } = asset; 'You have an existing position and open orders on this market.',
const interpolation = { amount, symbol }; {
if (marginMode === Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN) { count: orders.length,
marginChangeWarning = t( }
'Changing the margin mode will move {{amount}} {{symbol}} from your general account to fund the position.', );
interpolation } else if (!orders?.length) {
); positionWarning = t('You have an existing position on this market.');
} else { } else {
marginChangeWarning = t( positionWarning = t(
'Changing the margin mode and leverage will move {{amount}} {{symbol}} from your general account to fund the position.', 'youHaveOpenOrders',
interpolation 'You have open orders on this market.',
{
count: orders.length,
}
);
}
const amount = addDecimalsFormatNumber(
collateralIncreaseEstimate.toString(),
asset?.decimals
); );
const { symbol } = asset;
const interpolation = { amount, symbol };
if (marginMode === Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN) {
marginChangeWarning = t(
'Changing the margin mode will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
} else {
marginChangeWarning = t(
'Changing the margin mode and leverage will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
}
} }
return ( return (
<div className="mb-2"> <div className="mb-2">
<Notification {positionWarning && marginChangeWarning && (
intent={Intent.Warning} <Notification
message={ intent={Intent.Warning}
<> message={
<p>{positionWarning}</p> <>
<p>{marginChangeWarning}</p> <p>{positionWarning}</p>
</> <p>{marginChangeWarning}</p>
</>
}
/>
)}
<DealTicketMarginDetails
marginAccountBalance={marginAccountBalance}
generalAccountBalance={generalAccountBalance}
orderMarginAccountBalance={orderMarginAccountBalance}
assetSymbol={asset.symbol}
market={market}
positionEstimate={estimateMargin.estimatePosition}
side={
openVolume.startsWith('-')
? Schema.Side.SIDE_SELL
: Schema.Side.SIDE_BUY
} }
/> />
</div> </div>

View File

@ -9,7 +9,7 @@ import type {
} from '../hooks/use-form-values'; } from '../hooks/use-form-values';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
import { isPersistentOrder } from './time-in-force-persistance'; import { isPersistentOrder } from './time-in-force-persistence';
export const mapFormValuesToOrderSubmission = ( export const mapFormValuesToOrderSubmission = (
order: OrderFormValues, order: OrderFormValues,

View File

@ -2,9 +2,9 @@ import { OrderTimeInForce } from '@vegaprotocol/types';
import { import {
isNonPersistentOrder, isNonPersistentOrder,
isPersistentOrder, isPersistentOrder,
} from './time-in-force-persistance'; } from './time-in-force-persistence';
it('isNonPeristentOrder', () => { it('isNonPersistentOrder', () => {
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(true); expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(true);
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(true); expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(true);
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(false); expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(false);
@ -13,7 +13,7 @@ it('isNonPeristentOrder', () => {
expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFN)).toBe(false); expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFN)).toBe(false);
}); });
it('isPeristentOrder', () => { it('isPersistentOrder', () => {
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(false); expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(false);
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(false); expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(false);
expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(true); expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(true);

View File

@ -23,7 +23,6 @@ import {
SUBSCRIPTION_TIMEOUT, SUBSCRIPTION_TIMEOUT,
useNodeBasicStatus, useNodeBasicStatus,
useNodeSubscriptionStatus, useNodeSubscriptionStatus,
useResponseTime,
} from './row-data'; } from './row-data';
import { BLOCK_THRESHOLD, RowData } from './row-data'; import { BLOCK_THRESHOLD, RowData } from './row-data';
import { CUSTOM_NODE_KEY } from '../../types'; import { CUSTOM_NODE_KEY } from '../../types';
@ -162,19 +161,6 @@ describe('useNodeBasicStatus', () => {
}); });
}); });
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});
describe('RowData', () => { describe('RowData', () => {
const props = { const props = {
id: '0', id: '0',

View File

@ -1,4 +1,3 @@
import { isValidUrl } from '@vegaprotocol/utils';
import { TradingRadio } from '@vegaprotocol/ui-toolkit'; import { TradingRadio } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CUSTOM_NODE_KEY } from '../../types'; import { CUSTOM_NODE_KEY } from '../../types';
@ -8,6 +7,7 @@ import {
} from '../../utils/__generated__/NodeCheck'; } from '../../utils/__generated__/NodeCheck';
import { LayoutCell } from './layout-cell'; import { LayoutCell } from './layout-cell';
import { useT } from '../../use-t'; import { useT } from '../../use-t';
import { useResponseTime } from '../../utils/time';
export const POLL_INTERVAL = 1000; export const POLL_INTERVAL = 1000;
export const SUBSCRIPTION_TIMEOUT = 3000; export const SUBSCRIPTION_TIMEOUT = 3000;
@ -108,20 +108,6 @@ export const useNodeBasicStatus = () => {
}; };
}; };
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => {
if (!isValidUrl(url)) return;
if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } =
(requests.length && requests[requests.length - 1]) || {};
setResponseTime(duration);
}, [url, trigger]);
return { responseTime };
};
export const RowData = ({ export const RowData = ({
id, id,
url, url,

View File

@ -10,6 +10,7 @@ import {
getUserEnabledFeatureFlags, getUserEnabledFeatureFlags,
setUserEnabledFeatureFlag, setUserEnabledFeatureFlag,
} from './use-environment'; } from './use-environment';
import { canMeasureResponseTime, measureResponseTime } from '../utils/time';
const noop = () => { const noop = () => {
/* no op*/ /* no op*/
@ -17,6 +18,10 @@ const noop = () => {
jest.mock('@vegaprotocol/apollo-client'); jest.mock('@vegaprotocol/apollo-client');
jest.mock('zustand'); jest.mock('zustand');
jest.mock('../utils/time');
const mockCanMeasureResponseTime = canMeasureResponseTime as jest.Mock;
const mockMeasureResponseTime = measureResponseTime as jest.Mock;
const mockCreateClient = createClient as jest.Mock; const mockCreateClient = createClient as jest.Mock;
const createDefaultMockClient = () => { const createDefaultMockClient = () => {
@ -155,6 +160,14 @@ describe('useEnvironment', () => {
const fastNode = 'https://api.n01.foo.vega.xyz'; const fastNode = 'https://api.n01.foo.vega.xyz';
const fastWait = 1000; const fastWait = 1000;
const nodes = [slowNode, fastNode]; const nodes = [slowNode, fastNode];
mockCanMeasureResponseTime.mockImplementation(() => true);
mockMeasureResponseTime.mockImplementation((url: string) => {
if (url === slowNode) return slowWait;
if (url === fastNode) return fastWait;
return Infinity;
});
// @ts-ignore: typscript doesn't recognise the mock implementation // @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes })); global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
@ -168,7 +181,7 @@ describe('useEnvironment', () => {
statistics: { statistics: {
chainId: 'chain-id', chainId: 'chain-id',
blockHeight: '100', blockHeight: '100',
vegaTime: new Date().toISOString(), vegaTime: new Date(1).toISOString(),
}, },
}, },
}); });
@ -196,7 +209,8 @@ describe('useEnvironment', () => {
expect(result.current.nodes).toEqual(nodes); expect(result.current.nodes).toEqual(nodes);
}); });
jest.runAllTimers(); jest.advanceTimersByTime(2000);
// jest.runAllTimers();
await waitFor(() => { await waitFor(() => {
expect(result.current.status).toEqual('success'); expect(result.current.status).toEqual('success');

View File

@ -19,6 +19,9 @@ import { compileErrors } from '../utils/compile-errors';
import { envSchema } from '../utils/validate-environment'; import { envSchema } from '../utils/validate-environment';
import { tomlConfigSchema } from '../utils/validate-configuration'; import { tomlConfigSchema } from '../utils/validate-configuration';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import orderBy from 'lodash/orderBy';
import first from 'lodash/first';
import { canMeasureResponseTime, measureResponseTime } from '../utils/time';
type Client = ReturnType<typeof createClient>; type Client = ReturnType<typeof createClient>;
type ClientCollection = { type ClientCollection = {
@ -38,8 +41,17 @@ export type EnvStore = Env & Actions;
const VERSION = 1; const VERSION = 1;
export const STORAGE_KEY = `vega_url_${VERSION}`; export const STORAGE_KEY = `vega_url_${VERSION}`;
const QUERY_TIMEOUT = 3000;
const SUBSCRIPTION_TIMEOUT = 3000; const SUBSCRIPTION_TIMEOUT = 3000;
const raceAgainst = (timeout: number): Promise<false> =>
new Promise((resolve) => {
setTimeout(() => {
resolve(false);
}, timeout);
});
/** /**
* Fetch and validate a vega node configuration * Fetch and validate a vega node configuration
*/ */
@ -64,53 +76,88 @@ const fetchConfig = async (url?: string) => {
const findNode = async (clients: ClientCollection): Promise<string | null> => { const findNode = async (clients: ClientCollection): Promise<string | null> => {
const tests = Object.entries(clients).map((args) => testNode(...args)); const tests = Object.entries(clients).map((args) => testNode(...args));
try { try {
const url = await Promise.any(tests); const nodes = await Promise.all(tests);
return url; const responsiveNodes = nodes
} catch { .filter(([, q, s]) => q && s)
.map(([url, q]) => {
return {
url,
...q,
};
});
// more recent and faster at the top
const ordered = orderBy(
responsiveNodes,
[(n) => n.blockHeight, (n) => n.vegaTime, (n) => n.responseTime],
['desc', 'desc', 'asc']
);
const best = first(ordered);
return best ? best.url : null;
} catch (err) {
// All tests rejected, no suitable node found // All tests rejected, no suitable node found
return null; return null;
} }
}; };
type Maybe<T> = T | false;
type QueryTestResult = {
blockHeight: number;
vegaTime: Date;
responseTime: number;
};
type SubscriptionTestResult = true;
type NodeTestResult = [
/** url */
string,
Maybe<QueryTestResult>,
Maybe<SubscriptionTestResult>
];
/** /**
* Test a node for suitability for connection * Test a node for suitability for connection
*/ */
const testNode = async ( const testNode = async (
url: string, url: string,
client: Client client: Client
): Promise<string | null> => { ): Promise<NodeTestResult> => {
const results = await Promise.all([ const results = await Promise.all([
// these promises will only resolve with true/false testQuery(client, url),
testQuery(client),
testSubscription(client), testSubscription(client),
]); ]);
if (results[0] && results[1]) { return [url, ...results];
return url;
}
const message = `Tests failed for node: ${url}`;
console.warn(message);
// throwing here will mean this tests is ignored and a different
// node that hopefully does resolve will fulfill the Promise.any
throw new Error(message);
}; };
/** /**
* Run a test query on a client * Run a test query on a client
*/ */
const testQuery = async (client: Client) => { const testQuery = (
try { client: Client,
const result = await client.query<NodeCheckQuery>({ url: string
query: NodeCheckDocument, ): Promise<Maybe<QueryTestResult>> => {
}); const test: Promise<Maybe<QueryTestResult>> = new Promise((resolve) =>
if (!result || result.error) { client
return false; .query<NodeCheckQuery>({
} query: NodeCheckDocument,
return true; })
} catch (err) { .then((result) => {
return false; if (result && !result.error) {
} const res = {
blockHeight: Number(result.data.statistics.blockHeight),
vegaTime: new Date(result.data.statistics.vegaTime),
// only after a request has been sent we can retrieve the response time
responseTime: canMeasureResponseTime(url)
? measureResponseTime(url) || Infinity
: Infinity,
} as QueryTestResult;
resolve(res);
} else {
resolve(false);
}
})
.catch(() => resolve(false))
);
return Promise.race([test, raceAgainst(QUERY_TIMEOUT)]);
}; };
/** /**
@ -118,7 +165,9 @@ const testQuery = async (client: Client) => {
* that takes longer than SUBSCRIPTION_TIMEOUT ms to respond * that takes longer than SUBSCRIPTION_TIMEOUT ms to respond
* is deemed a failure * is deemed a failure
*/ */
const testSubscription = (client: Client) => { const testSubscription = (
client: Client
): Promise<Maybe<SubscriptionTestResult>> => {
return new Promise((resolve) => { return new Promise((resolve) => {
const sub = client const sub = client
.subscribe<NodeCheckTimeUpdateSubscription>({ .subscribe<NodeCheckTimeUpdateSubscription>({

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react';
import { useResponseTime } from './time';
const mockResponseTime = 50;
global.performance.getEntriesByName = jest.fn().mockReturnValue([
{
duration: mockResponseTime,
},
]);
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});

View File

@ -0,0 +1,25 @@
import { isValidUrl } from '@vegaprotocol/utils';
import { useEffect, useState } from 'react';
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => {
if (!canMeasureResponseTime(url)) return;
const duration = measureResponseTime(url);
setResponseTime(duration);
}, [url, trigger]);
return { responseTime };
};
export const canMeasureResponseTime = (url: string) => {
if (!isValidUrl(url)) return false;
if (typeof window.performance.getEntriesByName !== 'function') return false;
return true;
};
export const measureResponseTime = (url: string) => {
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } = (requests.length && requests[requests.length - 1]) || {};
return duration;
};

View File

@ -417,6 +417,7 @@
"myVolume_other": "My volume (last {{count}} epochs)", "myVolume_other": "My volume (last {{count}} epochs)",
"numberEpochs": "{{count}} epochs", "numberEpochs": "{{count}} epochs",
"numberEpochs_one": "{{count}} epoch", "numberEpochs_one": "{{count}} epoch",
"Rewards earned": "Rewards earned",
"Rewards paid out": "Rewards paid out", "Rewards paid out": "Rewards paid out",
"{{reward}}x": "{{reward}}x", "{{reward}}x": "{{reward}}x",
"userActive": "{{active}} trader: {{count}} epochs so far", "userActive": "{{active}} trader: {{count}} epochs so far",
@ -431,5 +432,19 @@
"{{assetSymbol}} Reward pot": "{{assetSymbol}} Reward pot", "{{assetSymbol}} Reward pot": "{{assetSymbol}} Reward pot",
"{{checkedAssets}} Assets": "{{checkedAssets}} Assets", "{{checkedAssets}} Assets": "{{checkedAssets}} Assets",
"{{distance}} ago": "{{distance}} ago", "{{distance}} ago": "{{distance}} ago",
"{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision" "{{instrumentCode}} liquidity provision": "{{instrumentCode}} liquidity provision",
"My team": "My team",
"Profile": "Profile",
"Last {{games}} games result_one": "Last game result",
"Last {{games}} games result_other": "Last {{games}} games result",
"Leaderboard": "Leaderboard",
"View all teams": "View all teams",
"Competitions": "Competitions",
"Be a team player! Participate in games and work together to rake in as much profit to win.": "Be a team player! Participate in games and work together to rake in as much profit to win.",
"Create a public team": "Create a public team",
"Create a private team": "Create a private team",
"Choose a team": "Choose a team",
"Join a team": "Join a team",
"Solo team / lone wolf": "Solo team / lone wolf",
"Choose a team to get involved": "Choose a team to get involved"
} }

View File

@ -67,26 +67,6 @@ query EstimatePosition(
# we can set this variable to true so that we can format with market.decimalPlaces # we can set this variable to true so that we can format with market.decimalPlaces
scaleLiquidationPriceToMarketDecimals: true scaleLiquidationPriceToMarketDecimals: true
) { ) {
margin {
worstCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
bestCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
}
collateralIncreaseEstimate { collateralIncreaseEstimate {
worstCase worstCase
bestCase bestCase

View File

@ -33,7 +33,7 @@ export type EstimatePositionQueryVariables = Types.Exact<{
}>; }>;
export type EstimatePositionQuery = { __typename?: 'Query', estimatePosition?: { __typename?: 'PositionEstimate', margin: { __typename?: 'MarginEstimate', worstCase: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginMode: Types.MarginMode, marginFactor: string, orderMarginLevel: string }, bestCase: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginMode: Types.MarginMode, marginFactor: string, orderMarginLevel: string } }, collateralIncreaseEstimate: { __typename?: 'CollateralIncreaseEstimate', worstCase: string, bestCase: string }, liquidation?: { __typename?: 'LiquidationEstimate', worstCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string }, bestCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string } } | null } | null }; export type EstimatePositionQuery = { __typename?: 'Query', estimatePosition?: { __typename?: 'PositionEstimate', collateralIncreaseEstimate: { __typename?: 'CollateralIncreaseEstimate', worstCase: string, bestCase: string }, liquidation?: { __typename?: 'LiquidationEstimate', worstCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string }, bestCase: { __typename?: 'LiquidationPrice', open_volume_only: string, including_buy_orders: string, including_sell_orders: string } } | null } | null };
export const PositionFieldsFragmentDoc = gql` export const PositionFieldsFragmentDoc = gql`
fragment PositionFields on Position { fragment PositionFields on Position {
@ -144,26 +144,6 @@ export const EstimatePositionDocument = gql`
includeCollateralIncreaseInAvailableCollateral: $includeCollateralIncreaseInAvailableCollateral includeCollateralIncreaseInAvailableCollateral: $includeCollateralIncreaseInAvailableCollateral
scaleLiquidationPriceToMarketDecimals: true scaleLiquidationPriceToMarketDecimals: true
) { ) {
margin {
worstCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
bestCase {
maintenanceLevel
searchLevel
initialLevel
collateralReleaseLevel
marginMode
marginFactor
orderMarginLevel
}
}
collateralIncreaseEstimate { collateralIncreaseEstimate {
worstCase worstCase
bestCase bestCase

View File

@ -1,7 +1,6 @@
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { EstimatePositionQuery } from './__generated__/Positions'; import type { EstimatePositionQuery } from './__generated__/Positions';
import { MarginMode } from '@vegaprotocol/types';
export const estimatePositionQuery = ( export const estimatePositionQuery = (
override?: PartialDeep<EstimatePositionQuery> override?: PartialDeep<EstimatePositionQuery>
@ -9,26 +8,6 @@ export const estimatePositionQuery = (
const defaultResult: EstimatePositionQuery = { const defaultResult: EstimatePositionQuery = {
estimatePosition: { estimatePosition: {
__typename: 'PositionEstimate', __typename: 'PositionEstimate',
margin: {
bestCase: {
collateralReleaseLevel: '1000000',
initialLevel: '500000',
maintenanceLevel: '200000',
searchLevel: '300000',
marginFactor: '1',
orderMarginLevel: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
worstCase: {
collateralReleaseLevel: '1100000',
initialLevel: '600000',
maintenanceLevel: '300000',
searchLevel: '400000',
marginFactor: '1',
orderMarginLevel: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
},
collateralIncreaseEstimate: { collateralIncreaseEstimate: {
bestCase: '0', bestCase: '0',
worstCase: '0', worstCase: '0',

View File

@ -30,26 +30,6 @@ describe('LiquidationPrice', () => {
result: { result: {
data: { data: {
estimatePosition: { estimatePosition: {
margin: {
worstCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
orderMarginLevel: '0',
marginFactor: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
bestCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
orderMarginLevel: '0',
marginFactor: '0',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
},
collateralIncreaseEstimate: { collateralIncreaseEstimate: {
bestCase: '0', bestCase: '0',
worstCase: '0', worstCase: '0',

View File

@ -41,16 +41,17 @@ export const TradingView = ({
const chartContainerRef = useRef<HTMLDivElement>(null); const chartContainerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<IChartingLibraryWidget>(); const widgetRef = useRef<IChartingLibraryWidget>();
const datafeed = useDatafeed();
const prevMarketId = usePrevious(marketId); const prevMarketId = usePrevious(marketId);
const prevTheme = usePrevious(theme); const prevTheme = usePrevious(theme);
const datafeed = useDatafeed(marketId);
useEffect(() => { useEffect(() => {
// Widget already created // Widget already created
if (widgetRef.current !== undefined) { if (widgetRef.current !== undefined) {
// Update the symbol if changed // Update the symbol if changed
if (marketId !== prevMarketId) { if (marketId !== prevMarketId) {
datafeed.setSymbol(marketId);
widgetRef.current.setSymbol( widgetRef.current.setSymbol(
marketId, marketId,
(interval ? interval : '15') as TVResolutionString, (interval ? interval : '15') as TVResolutionString,

View File

@ -44,14 +44,22 @@ const configurationData: DatafeedConfiguration = {
supported_resolutions: supportedResolutions as ResolutionString[], supported_resolutions: supportedResolutions as ResolutionString[],
} as const; } as const;
export const useDatafeed = () => { // HACK: local handle for market id
let requestedSymbol: string | undefined = undefined;
export const useDatafeed = (marketId: string) => {
const hasHistory = useRef(false); const hasHistory = useRef(false);
const subRef = useRef<Subscription>(); const subRef = useRef<Subscription>();
const client = useApolloClient(); const client = useApolloClient();
const datafeed = useMemo(() => { const datafeed = useMemo(() => {
const feed: IBasicDataFeed = { const feed: IBasicDataFeed & { setSymbol: (symbol: string) => void } = {
setSymbol: (symbol: string) => {
// re-setting the symbol so it could be consumed by `resolveSymbol`
requestedSymbol = symbol;
},
onReady: (callback) => { onReady: (callback) => {
requestedSymbol = marketId;
setTimeout(() => callback(configurationData)); setTimeout(() => callback(configurationData));
}, },
@ -68,7 +76,7 @@ export const useDatafeed = () => {
const result = await client.query<SymbolQuery, SymbolQueryVariables>({ const result = await client.query<SymbolQuery, SymbolQueryVariables>({
query: SymbolDocument, query: SymbolDocument,
variables: { variables: {
marketId, marketId: requestedSymbol || marketId,
}, },
}); });
@ -242,7 +250,7 @@ export const useDatafeed = () => {
}; };
return feed; return feed;
}, [client]); }, [client, marketId]);
useEffect(() => { useEffect(() => {
return () => { return () => {