Compare commits

...
Author SHA1 Message Date
JonRay15 ba0af64bef Merge branch 'develop' into JonRay15-patch-2 2023-08-02 12:28:09 +01:00
JonRay15 be4fb779ef Update feature-epic.md 2023-08-02 12:26:40 +01:00
JonRay15 f2eedc434d Update feature-epic.md 2023-08-02 12:02:21 +01:00
m.ray fde49e5446 fix(trading): order stopped misleading message (#4455) 2023-08-02 11:52:07 +01:00
JonRay15andMatthew Russell 45283161aa chore: create feature-epic issue template (#4456)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-08-02 11:48:23 +01:00
Matthew Russell 4eacbbba1c chore: add default title 2023-08-02 11:47:59 +01:00
Bartłomiej GłowniaandDariusz Majcherczyk 375d447fa8 feat(trading): add stop orders table and form (#4265)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
2023-08-02 11:28:33 +01:00
Mikołaj Młodzikowski 1dd97a2bce feat(ci): add develop docker image (#4458) 2023-08-02 10:58:17 +02:00
JonRay15 e9188344cf Update feature-epic.md 2023-08-01 23:24:24 +01:00
JonRay15 16c18b972d Create feature-epic.md 2023-08-01 23:11:15 +01:00
Edd d4a1a9b193 fix(explorer): add tests and fix transactions url generation (#4454) 2023-08-01 16:35:40 +01:00
Edd 376c5241a8 feat(explorer): improve transaction pagination (#4435) 2023-08-01 14:41:16 +00:00
Ciaran McGhie 8e6d8517cf feat(ui-toolkit,tailwindcss-config): healthbar auction level indicator (#4406) 2023-08-01 09:44:03 +01:00
Matthew Russell 5f9ec222c1 feat(trading): navigation (#4375) 2023-07-31 17:08:55 +01:00
Edd 8954c41c0a fix(governance): remove incorrect individual reward percentage (#4443) 2023-07-31 15:23:52 +00:00
Mikołaj Młodzikowski 4414ab4f47 feat(ci): wait for publish job to complete via github API 2023-07-31 15:04:34 +02:00
Mikołaj Młodzikowski d1d4bacc68 feat(ci): patch mainnet release 2023-07-31 13:09:00 +02:00
Joe Tsang f476688c7f chore(governance): fix failing proposal test (#4434) 2023-07-31 09:58:52 +01:00
Sam Keen 8845222700 feat(governance): remove max limit on associate function (#4427) 2023-07-28 11:12:29 +00:00
Radosław Szpiech e64c464091 chore(trading): move cumulative vol testid in orderbook (#4420) 2023-07-28 11:15:40 +02:00
Joe Tsang b2c2d0d7d6 test(governance): add e2e test and acs for proposal enhancements (#4421) 2023-07-28 09:34:39 +01:00
Joe Tsang 1fb61d313c chore(governance): add acs for network upgrade banner and estimate (#4402) 2023-07-28 09:34:30 +01:00
Mikołaj Młodzikowski ccf5ff632c feat(ci): setup new tests for console (#4400) 2023-07-28 07:32:07 +02:00
Maciek 54d6aaf56f feat(trading): add market successor proposal banner (#4401) 2023-07-27 14:54:00 +00:00
Mikołaj Młodzikowski 736b262947 fix(ci): publish of static dist (#4414) 2023-07-27 13:52:00 +02:00
176 changed files with 5205 additions and 1585 deletions
+2 -1
View File
@@ -51,7 +51,8 @@
"ul": ["list"], "ul": ["list"],
"ol": ["list"] "ol": ["list"]
} }
] ],
"no-console": ["error", { "allow": ["warn", "error"] }]
} }
}, },
{ {
+29
View File
@@ -0,0 +1,29 @@
---
name: Feature Epic
about:
A template to capture and scope user requirements, high level process, and basic mockups for an upcoming feature as part of the initial core spec review process.
title: 'Epic: '
labels: feature-epic
---
## Core Feature
<Name>
## Tasks
- [ ] Define high level requirements
- [ ] Create basic mockups
- [ ] Update "API Requirements" in core spec
- [ ] Update "User-Interface Spec" in relevant front end repo
- [ ] Create detailed user stories using normal template
## High Level Requirements
## Basic Mockups
## Link to API Requirements in Core spec
## Link to User Interface Specs
## Linked User Stories
@@ -7,7 +7,7 @@ on:
jobs: jobs:
after-release: after-release:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
timeout-minutes: 30 timeout-minutes: 45
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -30,18 +30,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Wait for publish to complete
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.release.tag_name }}
check-name: '(CD) publish dist / trading'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
- name: resolve ipfs hashes for release - name: resolve ipfs hashes for release
run: | run: |
echo "Name: ${{ github.event.release.name }}" echo "Name: ${{ github.event.release.name }}"
echo "Description: ${{ github.event.release.body }}" echo "Description: ${{ github.event.release.body }}"
echo "Tag: ${{ github.event.release.tag_name }}" echo "Tag: ${{ github.event.release.tag_name }}"
commit="$(git rev-list -n 1 ${{ github.event.release.tag_name }})" docker run --rm vegaprotocol/trading:mainnet cat /ipfs-hash > ipfs-hash
echo "Commit: $commit"
until docker pull vegaprotocol/trading:$commit; do
echo "Image not pushed yet, waiting 60 seconds"
sleep 60
done
docker run --rm vegaprotocol/trading:$commit cat /ipfs-hash > ipfs-hash
curl -L https://dist.ipfs.tech/kubo/v0.20.0/kubo_v0.20.0_linux-amd64.tar.gz -o kubo.tgz curl -L https://dist.ipfs.tech/kubo/v0.20.0/kubo_v0.20.0_linux-amd64.tar.gz -o kubo.tgz
tar -xzf kubo.tgz tar -xzf kubo.tgz
export PATH="$PATH:$PWD/kubo" export PATH="$PATH:$PWD/kubo"
+9
View File
@@ -120,6 +120,15 @@ jobs:
preview_explorer: ${{ env.PREVIEW_EXPLORER }} preview_explorer: ${{ env.PREVIEW_EXPLORER }}
preview_tools: ${{ env.PREVIEW_TOOLS }} preview_tools: ${{ env.PREVIEW_TOOLS }}
console-e2e:
needs: lint-test-build
name: '(CI) console python'
uses: ./.github/workflows/console-test-run.yml
secrets: inherit
if: ${{ contains(fromJSON(needs.lint-test-build.outputs.projects), 'trading') && github.event_name == 'pull_request' }}
with:
github-sha: ${{ github.event.pull_request.head.sha || github.sha }}
cypress: cypress:
needs: lint-test-build needs: lint-test-build
name: '(CI) cypress' name: '(CI) cypress'
+110 -27
View File
@@ -1,55 +1,138 @@
name: console-test-run name: (CI) Console tests
on: on:
workflow_call: workflow_call:
inputs: inputs:
github-sha: github-sha:
required: true required: true
type: string type: string
jobs: jobs:
console-test: run-tests:
timeout-minutes: 5 name: run-tests
runs-on: self-hosted-runner runs-on: ubuntu-22.04
timeout-minutes: 25
steps: steps:
#----------------------------------------------
# check-out frontend-monorepo
#----------------------------------------------
- name: Checkout console test repo
uses: actions/checkout@v3
with:
ref: ${{ inputs.github-sha }}
#----------------------------------------------
# cache node modules
#----------------------------------------------
- name: Cache node modules
id: cache
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-cache-node-modules-${{ hashFiles('yarn.lock') }}
# comment out "restore-keys" if you need to rebuild yarn from 0
restore-keys: |
${{ runner.os }}-cache-node-modules-
#----------------------------------------------
# setup node
#----------------------------------------------
- name: Setup node
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
# https://stackoverflow.com/questions/61010294/how-to-cache-yarn-packages-in-github-actions
cache: yarn
#----------------------------------------------
# install deps if cache missing
#----------------------------------------------
- name: yarn install
if: steps.cache.outputs.cache-hit != 'true'
run: yarn install --pure-lockfile
#----------------------------------------------
# build trading
#----------------------------------------------
- name: Build affected spec
run: |
yarn env-cmd -f ./apps/trading/.env.stagnet1 yarn nx export trading
#----------------------------------------------
# run trading server
#----------------------------------------------
- name: Run trading server
run: |
docker run -d -p 80:4200 -v $PWD/docker/nginx.conf:/etc/nginx/conf.d/default.conf -v $PWD/dist/apps/trading/exported:/usr/share/nginx/html nginx:1.23-alpine@sha256:6318314189b40e73145a48060bff4783a116c34cc7241532d0d94198fb2c9629
sleep 5
docker ps
#----------------------------------------------
# check if container persists between runs
#----------------------------------------------
- name: Check server
run: |
docker ps
#----------------------------------------------
# check-out tests repo
#----------------------------------------------
- name: Checkout console test repo - name: Checkout console test repo
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
repository: vegaprotocol/console-test repository: vegaprotocol/console-test
path: './console-test' path: './console-test'
#----------------------------------------------
- name: Set up Python # set-up python
#----------------------------------------------
- name: Set up python
id: setup-python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: '3.10.11' python-version: '3.10.11'
#----------------------------------------------
# ----- install & configure poetry -----
#----------------------------------------------
- name: Install Poetry - name: Install Poetry
uses: snok/install-poetry@v1 uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
virtualenvs-path: console-test/.venv
#----------------------------------------------
# load cached venv if cache exists
#----------------------------------------------
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v3
with:
path: console-test/.venv
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
#----------------------------------------------
# install dependencies if cache does not exist
#----------------------------------------------
- name: Install dependencies - name: Install dependencies
run: |
poetry install --no-root
working-directory: ./console-test working-directory: ./console-test
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: load Binaries run: poetry install --no-interaction --no-root
run: | #----------------------------------------------
poetry run python -m vega_sim.tools.load_binaries # install vega binaries
#----------------------------------------------
- name: Install vega binaries
working-directory: ./console-test working-directory: ./console-test
run: poetry run python -m vega_sim.tools.load_binaries --force
- name: pull console #----------------------------------------------
run: | # install playwright
poetry run docker pull ghcr.io/vegaprotocol/frontend/trading:${{ inputs.github-sha }} #----------------------------------------------
- name: Update container_name in config.py
run: |
sed -i "s/container_name = \".*\"/container_name = \"vegaprotocol\/frontend\/trading:${{ inputs.github-sha }}\"/g" config.py
- name: install playwright - name: install playwright
run: poetry run playwright install run: poetry run playwright install
working-directory: ./console-test working-directory: ./console-test
#----------------------------------------------
- name: run tests # run tests
run: poetry run pytest --numprocesses auto #----------------------------------------------
- name: Run tests
working-directory: ./console-test working-directory: ./console-test
run: poetry run pytest --numprocesses auto
- name: Check files
run: |
ls -al .
ls -al console-test
#----------------------------------------------
# upload traces
#----------------------------------------------
- name: Upload Playwright Trace - name: Upload Playwright Trace
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
if: always() if: always()
+14 -4
View File
@@ -29,6 +29,12 @@ jobs:
echo IS_TESTNET_RELEASE=false >> $GITHUB_ENV echo IS_TESTNET_RELEASE=false >> $GITHUB_ENV
echo IS_IPFS_RELEASE=false >> $GITHUB_ENV echo IS_IPFS_RELEASE=false >> $GITHUB_ENV
echo IS_S3_RELEASE=false >> $GITHUB_ENV echo IS_S3_RELEASE=false >> $GITHUB_ENV
echo IS_DEV_IMAGE=false >> $GITHUB_ENV
- name: Is dev image
if: ${{ contains(github.ref, 'develop') && github.event_name == 'push' && matrix.app == 'trading' }}
run: |
echo IS_DEV_IMAGE=true >> $GITHUB_ENV
- name: Is PR - name: Is PR
if: ${{ github.event_name == 'pull_request' }} if: ${{ github.event_name == 'pull_request' }}
@@ -36,7 +42,7 @@ jobs:
echo IS_PR=true >> $GITHUB_ENV echo IS_PR=true >> $GITHUB_ENV
- name: Is mainnet release - name: Is mainnet release
if: ${{ contains(github.ref, 'release/mainnnet') && !contains(github.ref, 'mirror') }} if: ${{ contains(github.ref, 'release/mainnet') && !contains(github.ref, 'mirror') }}
run: | run: |
echo IS_MAINNET_RELEASE=true >> $GITHUB_ENV echo IS_MAINNET_RELEASE=true >> $GITHUB_ENV
@@ -75,7 +81,7 @@ jobs:
- name: Log in to the Container registry (docker hub) - name: Log in to the Container registry (docker hub)
uses: docker/login-action@v2 uses: docker/login-action@v2
if: ${{ env.IS_IPFS_RELEASE == 'true' }} if: ${{ env.IS_IPFS_RELEASE == 'true' || env.IS_DEV_IMAGE == 'true' }}
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -118,8 +124,12 @@ jobs:
elif [ "${{ matrix.app }}" = "ui-toolkit" ]; then elif [ "${{ matrix.app }}" = "ui-toolkit" ]; then
NODE_ENV=production yarn nx run ui-toolkit:build-storybook NODE_ENV=production yarn nx run ui-toolkit:build-storybook
DIST_LOCATION=dist/storybook/ui-toolkit DIST_LOCATION=dist/storybook/ui-toolkit
elif [ "${{ matrix.app }}" = "static" ]; then
yarn nx build static || (yarn install && yarn nx build static)
else else
$envCmd yarn nx build ${{ matrix.app }} || (yarn install && $envCmd yarn nx build ${{ matrix.app }}) $envCmd yarn nx build ${{ matrix.app }} || (yarn install && $envCmd yarn nx build ${{ matrix.app }})
fi
if [[ -z "$DIST_LOCATION" ]]; then
DIST_LOCATION=dist/apps/${{ matrix.app }} DIST_LOCATION=dist/apps/${{ matrix.app }}
fi fi
mv $DIST_LOCATION dist-result mv $DIST_LOCATION dist-result
@@ -169,7 +179,7 @@ jobs:
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
continue-on-error: true continue-on-error: true
id: dockerhub-push id: dockerhub-push
if: ${{ env.IS_IPFS_RELEASE == 'true' }} if: ${{ env.IS_IPFS_RELEASE == 'true' || env.IS_DEV_IMAGE == 'true' }}
with: with:
context: . context: .
file: docker/node-outside-docker.Dockerfile file: docker/node-outside-docker.Dockerfile
@@ -179,7 +189,7 @@ jobs:
ENV_NAME=${{ env.ENV_NAME }} ENV_NAME=${{ env.ENV_NAME }}
tags: | tags: |
vegaprotocol/${{ matrix.app }}:${{ github.sha }} vegaprotocol/${{ matrix.app }}:${{ github.sha }}
vegaprotocol/${{ matrix.app }}:${{ env.IS_MAINNET_RELEASE == 'true' && 'mainnet' || env.IS_TESTNET_RELEASE == 'true' && 'testnet' || '' }} vegaprotocol/${{ matrix.app }}:${{ env.IS_MAINNET_RELEASE == 'true' && 'mainnet' || env.IS_TESTNET_RELEASE == 'true' && 'testnet' || env.IS_DEV_IMAGE == 'true' && 'develop' || '' }}
- name: Publish dist as docker image (ghcr - retry) - name: Publish dist as docker image (ghcr - retry)
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
@@ -10,7 +10,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
Icon, Icon,
Button,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { FilterLabel } from './tx-filter-label'; import { FilterLabel } from './tx-filter-label';
@@ -100,15 +99,13 @@ export const TxsFilter = ({ filters, setFilters }: TxFilterProps) => {
<DropdownMenu <DropdownMenu
modal={false} modal={false}
trigger={ trigger={
<DropdownMenuTrigger className="ml-0"> <DropdownMenuTrigger>
<Button size="xs" data-testid="filter-trigger"> <FilterLabel filters={filters} />
<FilterLabel filters={filters} />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
} }
> >
<DropdownMenuContent> <DropdownMenuContent>
{filters.size > 0 ? null : ( {filters.size > 1 ? null : (
<> <>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onCheckedChange={() => setFilters(new Set(AllFilterOptions))} onCheckedChange={() => setFilters(new Set(AllFilterOptions))}
@@ -44,7 +44,7 @@ export const TxsListNavigation = ({
</Button> </Button>
<Button <Button
size="xs" size="xs"
disabled={!hasMoreTxs || loading} disabled={!hasMoreTxs}
onClick={() => { onClick={() => {
nextPage(); nextPage();
}} }}
@@ -86,14 +86,18 @@ describe('Txs infinite list item', () => {
render( render(
<MockedProvider> <MockedProvider>
<MemoryRouter> <MemoryRouter>
<TxsInfiniteListItem <table>
type="testType" <tbody>
submitter="testPubKey" <TxsInfiniteListItem
hash="testTxHash" type="testType"
block="1" submitter="testPubKey"
code={0} hash="testTxHash"
command={{}} block="1"
/> code={0}
command={{}}
/>
</tbody>
</table>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
@@ -14,9 +14,9 @@ const DEFAULT_TRUNCATE_LENGTH = 7;
export function getIdTruncateLength(screen: Screen): number { export function getIdTruncateLength(screen: Screen): number {
if (['xxxl', 'xxl'].includes(screen)) { if (['xxxl', 'xxl'].includes(screen)) {
return 64;
} else if (['xl', 'lg', 'md'].includes(screen)) {
return 32; return 32;
} else if (['xl', 'lg', 'md'].includes(screen)) {
return 16;
} }
return DEFAULT_TRUNCATE_LENGTH; return DEFAULT_TRUNCATE_LENGTH;
} }
@@ -5,11 +5,17 @@ import type { BlockExplorerTransactionResult } from '../../routes/types/block-ex
import { Side } from '@vegaprotocol/types'; import { Side } from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
const generateHash = (): string =>
Array.from(
{ length: 64 },
() => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]
).join('');
const generateTxs = (number: number): BlockExplorerTransactionResult[] => { const generateTxs = (number: number): BlockExplorerTransactionResult[] => {
return Array.from(Array(number)).map((_) => ({ return Array.from(Array(number)).map((_) => ({
block: '87901', block: '87901',
index: 2, index: 2,
hash: '0F8B98DA0923A50786B852D9CA11E051CACC4C733E1DB93D535C7D81DBD10F6F', hash: generateHash(),
submitter: submitter:
'4b782482f587d291e8614219eb9a5ee9280fa2c58982dee71d976782a9be1964', '4b782482f587d291e8614219eb9a5ee9280fa2c58982dee71d976782a9be1964',
type: 'Submit Order', type: 'Submit Order',
@@ -2,7 +2,7 @@ import { Table, TableRow } from '../table';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response'; import type { BlockExplorerTransactions } from '../../routes/types/block-explorer-response';
import { getTxsDataUrl } from '../../hooks/use-txs-data'; import { getTxsDataUrl } from '../../hooks/get-txs-data-url';
import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit';
import EmptyList from '../empty-list/empty-list'; import EmptyList from '../empty-list/empty-list';
import { TxsInfiniteListItem } from './txs-infinite-list-item'; import { TxsInfiniteListItem } from './txs-infinite-list-item';
@@ -14,7 +14,7 @@ interface TxsPerBlockProps {
export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => { export const TxsPerBlock = ({ blockHeight, txCount }: TxsPerBlockProps) => {
const filters = `filters[block.height]=${blockHeight}`; const filters = `filters[block.height]=${blockHeight}`;
const url = getTxsDataUrl({ limit: txCount.toString(), filters }); const url = getTxsDataUrl({ filters, count: txCount });
const { const {
state: { data, loading, error }, state: { data, loading, error },
} = useFetch<BlockExplorerTransactions>(url); } = useFetch<BlockExplorerTransactions>(url);
@@ -0,0 +1,59 @@
import { DATA_SOURCES } from '../config';
type IGetTxsDataFirstPage = {
baseUrl?: string;
count?: number;
party?: string;
filters?: string;
};
interface IGetTxsDataPrevious extends IGetTxsDataFirstPage {
before: string;
}
interface IGetTxsDataNext extends IGetTxsDataFirstPage {
after: string;
}
type IGetTxsDataUrl =
| IGetTxsDataPrevious
| IGetTxsDataNext
| IGetTxsDataFirstPage;
export const BE_TXS_PER_REQUEST = 25;
/**
* Properly encodes the filters and parameters for a request to the block explorer
* API for transactions. As the API uses a slightly less common format for encoding
* filters, some of it is more manual than you might expect.
*
* @param params An object containing the pagination and filters
* @returns string URL to call
*/
export const getTxsDataUrl = (params: IGetTxsDataUrl) => {
const baseUrl =
params.baseUrl || `${DATA_SOURCES.blockExplorerUrl}/transactions`;
const url = new URL(baseUrl);
const count = `${params.count || BE_TXS_PER_REQUEST}`;
if ('before' in params && params.before?.length > 0) {
url.searchParams.append('last', count);
url.searchParams.append('before', params.before);
} else if ('after' in params && params.after?.length > 0) {
url.searchParams.append('first', count);
url.searchParams.append('after', params.after);
} else {
url.searchParams.append('first', count);
}
// Hacky fix for param as array
let urlAsString = url.toString();
if (params.filters && params.filters?.length > 0) {
urlAsString += '&' + params.filters.replaceAll(' ', '%20');
}
if (params.party && params.party?.length > 0) {
urlAsString += `&filters[tx.submitter]=${params.party}`;
}
return urlAsString;
};
@@ -0,0 +1,48 @@
import { getTxsDataUrl } from './get-txs-data-url'; // import the function to be tested
describe('getTxsDataUrl', () => {
it('should return the correct URL without filters and party', () => {
const params = {
count: 10,
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?first=10';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with "before" in params', () => {
const params = {
count: 5,
before: '100.1',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?last=5&before=100.1';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with "after" in params', () => {
const params = {
count: 5,
after: '222.1',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl = 'https://example.com/transactions?first=5&after=222.1';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
it('should return the correct URL with filters and party', () => {
const params = {
count: 10,
filters: 'filters[cmd.type]=Made Up Transaction',
party: '1234',
baseUrl: 'https://example.com/transactions',
};
const expectedUrl =
'https://example.com/transactions?first=10&filters[cmd.type]=Made%20Up%20Transaction&filters[tx.submitter]=1234';
expect(getTxsDataUrl(params)).toEqual(expectedUrl);
});
});
+103 -92
View File
@@ -1,130 +1,141 @@
import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom';
import type { URLSearchParamsInit } from 'react-router-dom';
import { useCallback } from 'react';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import type { import type {
BlockExplorerTransactionResult, BlockExplorerTransactionResult,
BlockExplorerTransactions, BlockExplorerTransactions,
} from '../routes/types/block-explorer-response'; } from '../routes/types/block-explorer-response';
import { DATA_SOURCES } from '../config';
import isNumber from 'lodash/isNumber'; import isNumber from 'lodash/isNumber';
import { AllFilterOptions } from '../components/txs/tx-filter';
import type { FilterOption } from '../components/txs/tx-filter';
import { BE_TXS_PER_REQUEST, getTxsDataUrl } from './get-txs-data-url';
export interface TxsStateProps { export function getTypeFilters(filters?: Set<FilterOption>) {
txsData: BlockExplorerTransactionResult[]; if (!filters) {
hasMoreTxs: boolean; return '';
cursor: string; } else if (filters.size > 1) {
previousCursors: string[]; return '';
hasPreviousPage: boolean; }
const forcedSingleFilter = Array.from(filters)[0];
return `filters[cmd.type]=${forcedSingleFilter}`;
} }
export interface IUseTxsData { export interface IUseTxsData {
limit: number; count?: number;
filters?: string; before?: string;
after?: string;
party?: string;
filters?: Set<FilterOption>;
} }
interface IGetTxsDataUrl { export const useTxsData = ({
limit: string; count = 25,
filters?: string; before,
} after,
filters,
party,
}: IUseTxsData) => {
const [, setSearchParams] = useSearchParams();
let hasMoreTxs = true;
let txsData: BlockExplorerTransactionResult[] = [];
export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => { const url = getTxsDataUrl({
const url = new URL(`${DATA_SOURCES.blockExplorerUrl}/transactions`); filters: getTypeFilters(filters),
count,
if (limit) { before,
url.searchParams.append('limit', limit); after,
} party,
// Hacky fix for param as array
let urlAsString = url.toString();
if (filters) {
urlAsString += '&' + filters.replace(' ', '%20');
}
return urlAsString;
};
export const useTxsData = ({ limit, filters }: IUseTxsData) => {
const [
{ txsData, hasMoreTxs, cursor, previousCursors, hasPreviousPage },
setTxsState,
] = useState<TxsStateProps>({
txsData: [],
hasMoreTxs: false,
previousCursors: [],
cursor: '',
hasPreviousPage: false,
}); });
const url = getTxsDataUrl({ limit: limit.toString(), filters });
const { const {
state: { data, error, loading }, state: { data, error, loading },
refetch, refetch,
} = useFetch<BlockExplorerTransactions>(url, {}, true); } = useFetch<BlockExplorerTransactions>(url, {}, true);
useEffect(() => { if (!loading && data && isNumber(data.transactions.length)) {
if (!loading && data && isNumber(data.transactions.length)) { hasMoreTxs = data.transactions.length >= count;
setTxsState((prev) => { txsData = data.transactions;
return { }
...prev,
txsData: data.transactions,
hasMoreTxs: data.transactions.length >= limit,
cursor: data?.transactions.at(-1)?.cursor || '',
};
});
}
}, [loading, setTxsState, data, limit]);
const nextPage = useCallback(() => { const nextPage = useCallback(() => {
const c = data?.transactions.at(0)?.cursor; const after = data?.transactions.at(-1)?.cursor || '';
const newPreviousCursors = c ? [...previousCursors, c] : previousCursors; const params: URLSearchParamsInit = { after };
if (filters) {
setTxsState((prev) => ({ params.filters = Array.from(filters).join(',');
...prev, }
hasPreviousPage: true, setSearchParams(params);
previousCursors: newPreviousCursors, }, [filters, data, setSearchParams]);
}));
return refetch({
limit,
before: cursor,
});
}, [data, previousCursors, cursor, limit, refetch]);
const previousPage = useCallback(() => { const previousPage = useCallback(() => {
const previousCursor = [...previousCursors].pop(); const before = data?.transactions[0]?.cursor || '';
const newPreviousCursors = previousCursors.slice(0, -1); const params: URLSearchParamsInit = { before };
setTxsState((prev) => ({ if (filters && filters.size > 0 && filters.size === 1) {
...prev, params.filters = Array.from(filters)[0];
hasPreviousPage: newPreviousCursors.length > 0, }
previousCursors: newPreviousCursors, setSearchParams(params);
})); }, [filters, data, setSearchParams]);
return refetch({
limit,
before: previousCursor,
});
}, [previousCursors, limit, refetch]);
const refreshTxs = useCallback(async () => { const refreshTxs = useCallback(async () => {
setTxsState(() => ({ const params: URLSearchParamsInit = {};
txsData: [], if (filters && filters.size > 0 && filters.size === 1) {
cursor: '', params.filters = Array.from(filters)[0];
previousCursors: [], }
hasMoreTxs: false, setSearchParams(params);
hasPreviousPage: false,
}));
refetch({ limit }); refetch({ count: BE_TXS_PER_REQUEST });
}, [setTxsState, limit, refetch, filters]); // eslint-disable-line react-hooks/exhaustive-deps }, [setSearchParams, refetch, filters]);
const updateFilters = useCallback(
(newFilters: Set<FilterOption>) => {
const params: URLSearchParamsInit = {};
if (newFilters && newFilters.size === 1) {
params.filters = Array.from(newFilters)[0];
}
setSearchParams(params);
},
[setSearchParams]
);
return { return {
updateFilters,
txsData, txsData,
loading, loading,
error, error,
hasMoreTxs, hasMoreTxs,
hasPreviousPage,
previousCursors,
cursor,
refreshTxs, refreshTxs,
nextPage, nextPage,
previousPage, previousPage,
}; };
}; };
/**
* Returns a Set of filters based on the URLSearchParams, or
* defaults to all.
* @param params
* @returns Set
*/
export function getInitialFilters(params: URLSearchParams): Set<FilterOption> {
const defaultFilters = new Set(AllFilterOptions);
const p = params.get('filters');
if (!p) {
return defaultFilters;
}
const filters = new Set<FilterOption>();
p.split(',').forEach((f) => {
if (AllFilterOptions.includes(f as FilterOption)) {
filters.add(f as FilterOption);
}
});
if (filters.size === 0) {
return defaultFilters;
}
return filters;
}
@@ -204,6 +204,7 @@ describe('Block', () => {
(useFetch as jest.Mock).mockReturnValue({ (useFetch as jest.Mock).mockReturnValue({
state: { data: createBlockResponse(1), loading: false, error: null }, state: { data: createBlockResponse(1), loading: false, error: null },
}); });
render(renderComponent(1)); render(renderComponent(1));
await waitFor(() => screen.getByTestId('block-header')); await waitFor(() => screen.getByTestId('block-header'));
expect(screen.getByTestId('previous-block-button')).toHaveAttribute( expect(screen.getByTestId('previous-block-button')).toHaveAttribute(
@@ -15,9 +15,13 @@ import { PartyBlockAccounts } from './components/party-block-accounts';
import { isValidPartyId } from './components/party-id-error'; import { isValidPartyId } from './components/party-id-error';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; import { TxsListNavigation } from '../../../components/txs/tx-list-navigation';
import type { FilterOption } from '../../../components/txs/tx-filter';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter';
import { useSearchParams } from 'react-router-dom';
const Party = () => { const Party = () => {
const [params] = useSearchParams();
const [filters, setFilters] = useState(new Set(AllFilterOptions)); const [filters, setFilters] = useState(new Set(AllFilterOptions));
const { party } = useParams<{ party: string }>(); const { party } = useParams<{ party: string }>();
@@ -27,24 +31,21 @@ const Party = () => {
const partyId = toNonHex(party ? party : ''); const partyId = toNonHex(party ? party : '');
const { isMobile } = useScreenDimensions(); const { isMobile } = useScreenDimensions();
const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]); const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]);
const baseFilters = `filters[tx.submitter]=${partyId}`;
const f =
filters && filters.size === 1
? `${baseFilters}&filters[cmd.type]=${Array.from(filters)[0]}`
: baseFilters;
const { const {
hasMoreTxs,
nextPage, nextPage,
refreshTxs,
previousPage, previousPage,
error, error,
refreshTxs,
loading, loading,
txsData, txsData,
hasPreviousPage, hasMoreTxs,
updateFilters,
} = useTxsData({ } = useTxsData({
limit: 25, filters: filters.size === 1 ? filters : undefined,
filters: f, before: params.get('before') || undefined,
after: !params.get('before') ? params.get('after') || undefined : undefined,
party: partyId,
}); });
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
@@ -102,15 +103,21 @@ const Party = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={hasPreviousPage} hasPreviousPage={true}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter filters={filters} setFilters={setFilters} /> <TxsFilter
filters={filters}
setFilters={(f) => {
setFilters(f);
updateFilters(f as Set<FilterOption>);
}}
/>
</TxsListNavigation> </TxsListNavigation>
{!error && txsData ? ( {!error && txsData ? (
<TxsInfiniteList <TxsInfiniteList
hasMoreTxs={hasMoreTxs} hasMoreTxs={true}
areTxsLoading={loading} areTxsLoading={loading}
txs={txsData} txs={txsData}
loadMoreTxs={nextPage} loadMoreTxs={nextPage}
+19 -16
View File
@@ -1,14 +1,14 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { RouteTitle } from '../../../components/route-title'; import { RouteTitle } from '../../../components/route-title';
import { TxsInfiniteList } from '../../../components/txs'; import { TxsInfiniteList } from '../../../components/txs';
import { useTxsData } from '../../../hooks/use-txs-data'; import { useTxsData, getInitialFilters } from '../../../hooks/use-txs-data';
import { useDocumentTitle } from '../../../hooks/use-document-title'; import { useDocumentTitle } from '../../../hooks/use-document-title';
import { useState } from 'react'; import { useState } from 'react';
import { AllFilterOptions, TxsFilter } from '../../../components/txs/tx-filter'; import { TxsFilter } from '../../../components/txs/tx-filter';
import type { FilterOption } from '../../../components/txs/tx-filter';
import { TxsListNavigation } from '../../../components/txs/tx-list-navigation'; import { TxsListNavigation } from '../../../components/txs/tx-list-navigation';
import { useSearchParams } from 'react-router-dom';
const BE_TXS_PER_REQUEST = 25;
export const TxsList = () => { export const TxsList = () => {
useDocumentTitle(['Transactions']); useDocumentTitle(['Transactions']);
@@ -27,25 +27,22 @@ export const TxsList = () => {
* @returns {JSX.Element} Transaction List and controls * @returns {JSX.Element} Transaction List and controls
*/ */
export const TxsListFiltered = () => { export const TxsListFiltered = () => {
const [filters, setFilters] = useState(new Set(AllFilterOptions)); const [params] = useSearchParams();
const [filters, setFilters] = useState(getInitialFilters(params));
const f =
filters && filters.size === 1
? `filters[cmd.type]=${Array.from(filters)[0]}`
: '';
const { const {
hasMoreTxs,
nextPage, nextPage,
previousPage, previousPage,
error, error,
refreshTxs, refreshTxs,
loading, loading,
txsData, txsData,
hasPreviousPage, hasMoreTxs,
updateFilters,
} = useTxsData({ } = useTxsData({
limit: BE_TXS_PER_REQUEST, filters: filters.size === 1 ? filters : undefined,
filters: f, before: params.get('before') || undefined,
after: !params.get('before') ? params.get('after') || undefined : undefined,
}); });
return ( return (
@@ -54,11 +51,17 @@ export const TxsListFiltered = () => {
refreshTxs={refreshTxs} refreshTxs={refreshTxs}
nextPage={nextPage} nextPage={nextPage}
previousPage={previousPage} previousPage={previousPage}
hasPreviousPage={hasPreviousPage} hasPreviousPage={true}
loading={loading} loading={loading}
hasMoreTxs={hasMoreTxs} hasMoreTxs={hasMoreTxs}
> >
<TxsFilter filters={filters} setFilters={setFilters} /> <TxsFilter
filters={filters}
setFilters={(f) => {
setFilters(f);
updateFilters(f as Set<FilterOption>);
}}
/>
</TxsListNavigation> </TxsListNavigation>
<TxsInfiniteList <TxsInfiniteList
hasFilters={filters.size > 0} hasFilters={filters.size > 0}
@@ -218,17 +218,33 @@ describe(
getProposalInformationFromTable('Tokens for proposal') getProposalInformationFromTable('Tokens for proposal')
.should('have.text', (1).toFixed(2)) .should('have.text', (1).toFixed(2))
.and('be.visible'); .and('be.visible');
navigateTo(navigation.proposals);
cy.get<testFreeformProposal>('@rawProposal').then((rawProposal) => {
getProposalFromTitle(rawProposal.rationale.title).within(() => {
// 3002-PROP-021
cy.getByTestId('user-voted-yes').should('exist');
cy.getByTestId(viewProposalButton).click();
});
});
cy.getByTestId(changeVoteButton).should('be.visible').click(); cy.getByTestId(changeVoteButton).should('be.visible').click();
voteForProposal('against'); voteForProposal('against');
cy.getByTestId(proposalVoteProgressAgainstPercentage) cy.getByTestId(proposalVoteProgressAgainstPercentage)
.contains('100.00%') .contains('100.00%')
.and('be.visible'); .and('be.visible');
cy.getByTestId(voteBreakdownToggle).click();
getProposalInformationFromTable('Tokens against proposal') getProposalInformationFromTable('Tokens against proposal')
.should('have.text', (1).toFixed(2)) .should('have.text', (1).toFixed(2))
.and('be.visible'); .and('be.visible');
getProposalInformationFromTable('Number of voting parties') getProposalInformationFromTable('Number of voting parties')
.should('have.text', '1') .should('have.text', '1')
.and('be.visible'); .and('be.visible');
navigateTo(navigation.proposals);
cy.get<testFreeformProposal>('@rawProposal').then((rawProposal) => {
getProposalFromTitle(rawProposal.rationale.title).within(() => {
cy.getByTestId('user-voted-no').should('exist');
cy.getByTestId(viewProposalButton).click();
});
});
}); });
// 3001-VOTE-042, 3001-VOTE-057, 3001-VOTE-058, 3001-VOTE-059, 3001-VOTE-060 // 3001-VOTE-042, 3001-VOTE-057, 3001-VOTE-058, 3001-VOTE-059, 3001-VOTE-060
@@ -26,6 +26,8 @@ const votesTable = 'votes-table';
const openProposals = 'open-proposals'; const openProposals = 'open-proposals';
const proposalVoteProgressForPercentage = const proposalVoteProgressForPercentage =
'vote-progress-indicator-percentage-for'; 'vote-progress-indicator-percentage-for';
const majorityVoteReached = 'majority-reached';
const minParticipationReached = 'participation-reached';
const proposalTimeout = { timeout: 8000 }; const proposalTimeout = { timeout: 8000 };
context( context(
@@ -72,7 +74,7 @@ context(
}); });
}); });
// 3001-VOTE-046 3001-VOTE-044 3001-VOTE-074 3001-VOTE-074 // 3001-VOTE-020 3001-VOTE-021 3001-VOTE-046 3001-VOTE-044 3001-VOTE-074 3001-VOTE-074
it('Able to enact proposal by voting', function () { it('Able to enact proposal by voting', function () {
const proposalTitle = 'Add New proposal with short enactment'; const proposalTitle = 'Add New proposal with short enactment';
const proposalTx = createUpdateNetworkProposalTxBody(); const proposalTx = createUpdateNetworkProposalTxBody();
@@ -85,10 +87,18 @@ context(
cy.contains(proposalTitle) cy.contains(proposalTitle)
.parentsUntil(proposalListItem) .parentsUntil(proposalListItem)
.last() .last()
.within(() => cy.getByTestId(viewProposalButton).click()); .within(() => {
// 3001-VOTE-019 time to vote is highlighted red
cy.getByTestId('vote-details')
.find('span')
.should('have.class', 'text-vega-pink');
cy.getByTestId(viewProposalButton).click();
});
}); });
cy.getByTestId(proposalStatus).should('have.text', 'Open'); cy.getByTestId(proposalStatus).should('have.text', 'Open');
voteForProposal('for'); voteForProposal('for');
cy.getByTestId(majorityVoteReached).should('exist');
cy.getByTestId(minParticipationReached).should('exist');
cy.getByTestId(proposalStatus, proposalTimeout) cy.getByTestId(proposalStatus, proposalTimeout)
.should('have.text', 'Passed') .should('have.text', 'Passed')
.then(() => { .then(() => {
@@ -104,6 +114,14 @@ context(
cy.getByTestId(proposalVoteProgressForPercentage) cy.getByTestId(proposalVoteProgressForPercentage)
.contains('100.00%') .contains('100.00%')
.and('be.visible'); .and('be.visible');
navigateTo(navigation.proposals);
cy.contains(proposalTitle)
.parentsUntil(proposalListItem)
.last()
.within(() => {
cy.getByTestId(majorityVoteReached).should('exist');
cy.getByTestId(minParticipationReached).should('exist');
});
}); });
// 3001-VOTE-047 // 3001-VOTE-047
@@ -49,8 +49,8 @@ describe('Governance flow for proposal list', { tags: '@slow' }, function () {
navigateTo(navigation.proposals); navigateTo(navigation.proposals);
}); });
// 3001-VOTE-018
it('Newly created proposals list - proposals closest to closing date appear higher in list', function () { it('Newly created proposals list - proposals closest to closing date appear higher in list', function () {
// 3001-VOTE-005
const proposalDays = [364, 50, 2]; const proposalDays = [364, 50, 2];
for (let index = 0; index < proposalDays.length; index++) { for (let index = 0; index < proposalDays.length; index++) {
goToMakeNewProposal(governanceProposalType.RAW); goToMakeNewProposal(governanceProposalType.RAW);
@@ -120,16 +120,24 @@ describe('Governance flow for proposal list', { tags: '@slow' }, function () {
submitUniqueRawProposal({ proposalTitle: proposalTitle }); submitUniqueRawProposal({ proposalTitle: proposalTitle });
getProposalFromTitle(proposalTitle).within(() => { getProposalFromTitle(proposalTitle).within(() => {
// 3001-VOTE-039 // 3001-VOTE-039
cy.getByTestId(voteStatus).should( cy.getByTestId('participation-not-reached').should(
'have.text', 'have.text',
'Participation not reached' 'Min. participation not reached'
); );
cy.getByTestId(viewProposalButton).click(); cy.getByTestId(viewProposalButton).click();
}); });
voteForProposal('for'); voteForProposal('for');
navigateTo(navigation.proposals); navigateTo(navigation.proposals);
getProposalFromTitle(proposalTitle).within(() => { getProposalFromTitle(proposalTitle).within(() => {
cy.getByTestId(voteStatus).should('have.text', 'Set to pass'); cy.getByTestId(voteStatus).should(
'have.text',
'Currently expected to pass'
);
cy.getByTestId('user-voted-yes').should('exist');
cy.getByTestId('participation-reached').should(
'have.text',
'Min. participation reached'
);
cy.getByTestId(viewProposalButton).click(); cy.getByTestId(viewProposalButton).click();
}); });
cy.getByTestId(voteBreakDownToggle).click(); cy.getByTestId(voteBreakDownToggle).click();
@@ -351,6 +351,12 @@ context(
); );
stakingPageDisassociateAllTokens(); stakingPageDisassociateAllTokens();
}); });
it('Able to associate over 1 million tokens', function () {
cy.get(ethWalletAssociateButton).click();
stakingPageAssociateTokens('2000000', { approve: true });
stakingPageDisassociateAllTokens();
});
}); });
} }
); );
@@ -17,6 +17,7 @@ const governanceDocsUrl = 'https://vega.xyz/governance';
const networkUpgradeProposalListItem = 'protocol-upgrade-proposals-list-item'; const networkUpgradeProposalListItem = 'protocol-upgrade-proposals-list-item';
const closedProposals = 'closed-proposals'; const closedProposals = 'closed-proposals';
const closedProposalToggle = 'closed-proposals-toggle-networkUpgrades'; const closedProposalToggle = 'closed-proposals-toggle-networkUpgrades';
const protocolUpgradeTime = 'protocol-upgrade-time';
context( context(
'Governance Page - verify elements on page', 'Governance Page - verify elements on page',
@@ -174,6 +175,7 @@ context(
}); });
}); });
// 3009-NTWU-003 3009-NTWU-004 3009-NTWU-007
it('should see details of network upgrade proposal', function () { it('should see details of network upgrade proposal', function () {
mockNetworkUpgradeProposal(); mockNetworkUpgradeProposal();
navigateTo(navigation.proposals); navigateTo(navigation.proposals);
@@ -228,6 +230,7 @@ context(
cy.getByTestId(closedProposalToggle).should('not.exist'); cy.getByTestId(closedProposalToggle).should('not.exist');
}); });
// 3009-NTWU-001 3009-NTWU-002 3009-NTWU-006 3009-NTWU-009
it('should display network upgrade banner with estimate', function () { it('should display network upgrade banner with estimate', function () {
mockNetworkUpgradeProposal(); mockNetworkUpgradeProposal();
cy.visit('/'); cy.visit('/');
@@ -259,10 +262,18 @@ context(
.as('displayedEstimate'); .as('displayedEstimate');
cy.get('@displayedEstimate').then((estimateText) => { cy.get('@displayedEstimate').then((estimateText) => {
// Estimated time should automatically update every second // Estimated time should automatically update every second
cy.getByTestId('protocol-upgrade-time') cy.getByTestId(protocolUpgradeTime)
.invoke('text') .invoke('text')
.should('not.eq', estimateText); .should('not.eq', estimateText);
}); });
// time estimate on proposal detail
cy.getByTestId('protocol-upgrade-proposal').within(() => {
cy.get('@displayedEstimate').then((estimateText) => {
cy.getByTestId(protocolUpgradeTime)
.invoke('text')
.should('not.eq', estimateText);
});
});
}); });
} }
); );
+2 -7
View File
@@ -18,10 +18,7 @@ import { VegaWallet } from '../vega-wallet';
import { useLocation, useMatch } from 'react-router-dom'; import { useLocation, useMatch } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTelemetryDialog } from '../telemetry-dialog/telemetry-dialog'; import { useTelemetryDialog } from '../telemetry-dialog/telemetry-dialog';
import { import { ProtocolUpgradeCountdown } from '@vegaprotocol/proposals';
ProtocolUpgradeCountdown,
ProtocolUpgradeCountdownMode,
} from '@vegaprotocol/proposals';
export const SettingsLink = () => { export const SettingsLink = () => {
const { open, isOpen, close } = useTelemetryDialog(); const { open, isOpen, close } = useTelemetryDialog();
@@ -68,9 +65,7 @@ export const Nav = ({ theme }: Pick<NavigationProps, 'theme'>) => {
actions={ actions={
<> <>
<SettingsLink /> <SettingsLink />
<ProtocolUpgradeCountdown <ProtocolUpgradeCountdown />
mode={ProtocolUpgradeCountdownMode.IN_ESTIMATED_TIME_REMAINING}
/>
</> </>
} }
> >
+1 -1
View File
@@ -20,6 +20,6 @@ export const downloadJson = (jsonString: string, proposalTitle: string) => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
} catch (error) { } catch (error) {
console.log(error); console.error(error);
} }
}; };
@@ -5,7 +5,6 @@ import {
RewardsTable, RewardsTable,
} from '../shared-rewards-table-assets/shared-rewards-table-assets'; } from '../shared-rewards-table-assets/shared-rewards-table-assets';
import type { EpochIndividualReward } from './generate-epoch-individual-rewards-list'; import type { EpochIndividualReward } from './generate-epoch-individual-rewards-list';
import { useTranslation } from 'react-i18next';
interface EpochIndividualRewardsGridProps { interface EpochIndividualRewardsGridProps {
data: EpochIndividualReward; data: EpochIndividualReward;
@@ -22,14 +21,11 @@ interface RewardItemProps {
const DisplayReward = ({ const DisplayReward = ({
reward, reward,
decimals, decimals,
percentageOfTotal,
}: { }: {
reward: string; reward: string;
decimals: number; decimals: number;
percentageOfTotal?: string; percentageOfTotal?: string;
}) => { }) => {
const { t } = useTranslation();
if (Number(reward) === 0) { if (Number(reward) === 0) {
return <span className="text-vega-dark-300">-</span>; return <span className="text-vega-dark-300">-</span>;
} }
@@ -39,23 +35,12 @@ const DisplayReward = ({
description={ description={
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span>{formatNumber(toBigNum(reward, decimals), decimals)}</span> <span>{formatNumber(toBigNum(reward, decimals), decimals)}</span>
{percentageOfTotal && (
<span className="text-vega-dark-300">
({percentageOfTotal}% {t('ofTotalDistributed')})
</span>
)}
</div> </div>
} }
> >
<button> <button>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span>{formatNumber(toBigNum(reward, decimals), 4)}</span> <span>{formatNumber(toBigNum(reward, decimals), 4)}</span>
{percentageOfTotal && (
<span className="text-vega-dark-300">
({formatNumber(percentageOfTotal, 4).toString()}
%)
</span>
)}
</div> </div>
</button> </button>
</Tooltip> </Tooltip>
@@ -168,17 +168,6 @@ describe('generateEpochIndividualRewardsList', () => {
expect(result2[1].epoch).toEqual(1); expect(result2[1].epoch).toEqual(1);
}); });
it('correctly calculates the total value of rewards for an asset', () => {
const rewards = [reward1, reward4];
const result = generateEpochIndividualRewardsList({
rewards,
epochId: 1,
epochRewardSummaries: [],
});
expect(result[0].rewards[0].totalAmount).toEqual('200');
});
it('returns data in the expected shape', () => { it('returns data in the expected shape', () => {
// Just sanity checking the whole structure here // Just sanity checking the whole structure here
const rewards = [reward1, reward2, reward3, reward4]; const rewards = [reward1, reward2, reward3, reward4];
@@ -458,69 +447,4 @@ describe('generateEpochIndividualRewardsList', () => {
}, },
]); ]);
}); });
it('correctly calculates the percentage of two or more rewards by referencing the total rewards amount', () => {
const result = generateEpochIndividualRewardsList({
rewards: [
// reward1 is 100 usd, which is 10% of the total rewards amount
reward1,
{
rewardType: AccountType.ACCOUNT_TYPE_GLOBAL_REWARD,
amount: '200',
percentageOfTotal: '0.2',
receivedAt: new Date(),
asset: { id: 'usd', symbol: 'USD', name: 'USD', decimals: 6 },
party: { id: 'blah' },
epoch: { id: '1' },
},
],
epochId: 1,
epochRewardSummaries: [
{
__typename: 'EpochRewardSummary',
epoch: 1,
assetId: 'usd',
amount: '1000',
rewardType: AccountType.ACCOUNT_TYPE_GLOBAL_REWARD,
},
],
});
expect(result[0]).toEqual({
epoch: 1,
rewards: [
{
asset: 'USD',
decimals: 6,
totalAmount: '300',
rewardTypes: {
[AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE]: {
amount: '0',
percentageOfTotal: '0',
},
[AccountType.ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES]: {
amount: '0',
percentageOfTotal: '0',
},
[AccountType.ACCOUNT_TYPE_GLOBAL_REWARD]: {
amount: '300',
percentageOfTotal: '30',
},
[AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES]: {
amount: '0',
percentageOfTotal: '0',
},
[AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES]: {
amount: '0',
percentageOfTotal: '0',
},
[AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS]: {
amount: '0',
percentageOfTotal: '0',
},
},
},
],
});
});
}); });
@@ -59,7 +59,6 @@ export const generateEpochIndividualRewardsList = ({
const epochIndividualRewards = rewards.reduce((acc, reward) => { const epochIndividualRewards = rewards.reduce((acc, reward) => {
const epochId = reward.epoch.id; const epochId = reward.epoch.id;
const assetName = reward.asset.name; const assetName = reward.asset.name;
const assetId = reward.asset.id;
const assetDecimals = reward.asset.decimals; const assetDecimals = reward.asset.decimals;
const rewardType = reward.rewardType; const rewardType = reward.rewardType;
const amount = reward.amount; const amount = reward.amount;
@@ -77,13 +76,6 @@ export const generateEpochIndividualRewardsList = ({
const epoch = acc.get(epochId); const epoch = acc.get(epochId);
// matchingTotalReward is the total awarded for all users for the reward type in the epoch of the asset // matchingTotalReward is the total awarded for all users for the reward type in the epoch of the asset
const matchingTotalRewardAmount = epochRewardSummaries.find(
(summary) =>
summary.epoch === Number(epochId) &&
summary.assetId === assetId &&
summary.rewardType === rewardType
)?.amount;
let asset = epoch?.rewards.find((r) => r.asset === assetName); let asset = epoch?.rewards.find((r) => r.asset === assetName);
if (!asset) { if (!asset) {
@@ -106,14 +98,7 @@ export const generateEpochIndividualRewardsList = ({
asset.rewardTypes[rewardType] = { asset.rewardTypes[rewardType] = {
amount: newAmount, amount: newAmount,
percentageOfTotal: matchingTotalRewardAmount percentageOfTotal: percentageOfTotal,
? new BigNumber(newAmount)
.dividedBy(matchingTotalRewardAmount)
.multipliedBy(100)
.toString()
: // this should never be reached, if there's an individual reward there should
// always be a reward total from the api too, but set it as a fallback just in case
percentageOfTotal,
}; };
} }
@@ -61,8 +61,6 @@ export const RewardsPage = () => {
error: paramsError, error: paramsError,
} = useNetworkParams([NetworkParams.reward_staking_delegation_payoutDelay]); } = useNetworkParams([NetworkParams.reward_staking_delegation_payoutDelay]);
console.log('params', params);
const payoutDuration = useMemo(() => { const payoutDuration = useMemo(() => {
if (!params) { if (!params) {
return 0; return 0;
@@ -8,9 +8,10 @@ import { TxState } from '../../../hooks/transaction-reducer';
import { useTransaction } from '../../../hooks/use-transaction'; import { useTransaction } from '../../../hooks/use-transaction';
import { BigNumber } from '../../../lib/bignumber'; import { BigNumber } from '../../../lib/bignumber';
import { AssociateInfo } from './associate-info'; import { AssociateInfo } from './associate-info';
import { removeDecimal, toBigNum } from '@vegaprotocol/utils'; import { toBigNum } from '@vegaprotocol/utils';
import type { EthereumConfig } from '@vegaprotocol/web3'; import type { EthereumConfig } from '@vegaprotocol/web3';
import { useBalances } from '../../../lib/balances/balances-store'; import { useBalances } from '../../../lib/balances/balances-store';
import { MaxUint256 } from '@ethersproject/constants';
export const WalletAssociate = ({ export const WalletAssociate = ({
perform, perform,
@@ -42,7 +43,7 @@ export const WalletAssociate = ({
} = useTransaction(() => { } = useTransaction(() => {
return token.approve( return token.approve(
ethereumConfig.staking_bridge_contract.address, ethereumConfig.staking_bridge_contract.address,
removeDecimal('1000000', decimals).toString() MaxUint256.toString()
); );
}); });
@@ -535,6 +535,7 @@ function checkIfDataAndTimeOfCreationAndUpdateIsEqual(date: string) {
// unexpected latency // unexpected latency
const minBefore = subSeconds(new Date(), 5); const minBefore = subSeconds(new Date(), 5);
const maxAfter = addSeconds(new Date(), 5); const maxAfter = addSeconds(new Date(), 5);
// eslint-disable-next-line no-console
console.log(maxAfter); console.log(maxAfter);
const date = new Date($dateTime.toString()); const date = new Date($dateTime.toString());
expect(isAfter(date, minBefore) && isBefore(date, maxAfter)).to.equal( expect(isAfter(date, minBefore) && isBefore(date, maxAfter)).to.equal(
@@ -68,22 +68,4 @@ describe('home', { tags: '@regression' }, () => {
cy.getByTestId('connect').click(); cy.getByTestId('connect').click();
}); });
}); });
describe('Network switcher', () => {
before(() => {
cy.mockTradingPage();
cy.mockSubscription();
cy.visit('/');
});
it('switch to fairground network and check status & incidents link', () => {
// 0006-NETW-002
// 0006-NETW-003
cy.getByTestId('navigation')
.find('[data-testid="network-switcher"]')
.should('have.text', 'Custom')
.click();
cy.getByTestId('network-item').contains('Fairground testnet');
});
});
}); });
@@ -84,7 +84,7 @@ describe('markets proposed table', { tags: '@smoke' }, () => {
.find('button') .find('button')
.click(); .click();
const dropdownContent = '[data-testid="market-actions-content"]'; const dropdownContent = '[data-testid="proposal-actions-content"]';
const dropdownContentItem = '[role="menuitem"]'; const dropdownContentItem = '[role="menuitem"]';
// 6001-MARK-059 // 6001-MARK-059
@@ -100,7 +100,6 @@ describe('markets proposed table', { tags: '@smoke' }, () => {
'VEGA_TOKEN_URL' 'VEGA_TOKEN_URL'
)}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829` )}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829`
); );
cy.getByTestId('market-actions-content').click();
}); });
// 6001-MARK-060 // 6001-MARK-060
@@ -214,11 +213,12 @@ describe('no markets proposed', { tags: '@smoke', testIsolation: true }, () => {
aliasGQLQuery(req, 'ProposalsList', proposal); aliasGQLQuery(req, 'ProposalsList', proposal);
}); });
cy.mockSubscription(); cy.mockSubscription();
cy.visit('/#/markets/all');
cy.get('[data-testid="Proposed markets"]').click();
}); });
it('can see no markets message', () => { it('can see no markets message', () => {
cy.visit('/#/markets/all');
cy.get('[data-testid="Proposed markets"]').click();
// 6001-MARK-061 // 6001-MARK-061
cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets'); cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets');
}); });
@@ -1,114 +0,0 @@
import { mockConnectWallet } from '@vegaprotocol/cypress';
describe('Navbar', { tags: '@smoke' }, () => {
beforeEach(() => {
cy.clearAllLocalStorage();
cy.mockTradingPage();
cy.mockSubscription();
cy.visit('/');
cy.wait('@Markets');
cy.wait('@MarketsData');
});
const pages = [
{ name: 'Markets', link: '#/markets/all' },
{ name: 'Trading', link: '#/markets' },
{ name: 'Portfolio', link: '#/portfolio' },
];
describe('desktop view', () => {
pages.forEach(({ name, link }) => {
it(`${name} should be correctly rendered`, () => {
cy.get('nav')
.find(`a[data-testid=${name}]:visible`)
.then((element) => {
cy.wrap(element).click();
cy.location('hash').should('contain', link);
});
});
});
it('Resources dropdown should be correctly rendered', () => {
const resourceSelector = 'ul li:contains(Resources)';
['Docs', 'Give Feedback'].forEach((text, index) => {
cy.get('nav').find(resourceSelector).contains('Resources').click();
cy.get('nav')
.find(resourceSelector)
.find('.navigation-content li')
.eq(index)
.find('a')
.then((element) => {
expect(element.attr('target')).to.eq('_blank');
expect(element.attr('href')).to.not.be.empty;
expect(element.text()).to.eq(text);
});
});
});
it('Disclaimer should be presented after choosing from menu', () => {
cy.get('nav')
.find('ul li:contains(Resources)')
.contains('Resources')
.click();
cy.getByTestId('Disclaimer').eq(0).click();
cy.location('hash').should('equal', '#/disclaimer');
cy.get('p').contains(
'Vega is a decentralised peer-to-peer protocol that can be used to trade derivatives with cryptoassets.'
);
});
});
describe('mobile view', () => {
const viewportHeight = Cypress.config('viewportHeight');
const viewportWidth = Cypress.config('viewportWidth');
before(() => {
// a little hack to keep the viewport size between tests (cypress bug)
Cypress.config({
viewportWidth: 560,
viewportHeight: 890,
});
cy.viewport(560, 890);
});
describe('wallet drawer', () => {
it('wallet drawer should be correctly rendered', () => {
mockConnectWallet();
cy.connectVegaWallet(true);
cy.getByTestId('connect-vega-wallet-mobile').click();
cy.getByTestId('wallets-drawer').should('be.visible');
cy.getByTestId('wallets-drawer').within((el) => {
cy.wrap(el).get('button').contains('Disconnect').click();
});
cy.getByTestId('wallets-drawer').should('not.be.visible');
});
});
describe('menu drawer', () => {
pages.forEach(({ name, link }) => {
it(`${name} should be correctly rendered`, () => {
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').should('be.visible');
cy.getByTestId('menu-drawer').within((el) => {
cy.wrap(el).getByTestId(name).click();
cy.location('hash').should('contain', link);
});
});
});
it('Menu drawer should not be visible until opened', () => {
cy.getByTestId('menu-drawer').should('not.be.visible');
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').should('be.visible');
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').should('not.be.visible');
});
});
after(() => {
// a little hack to keep the viewport size between tests (cypress bug)
Cypress.config({
viewportWidth,
viewportHeight,
});
});
});
});
@@ -171,6 +171,7 @@ describe(
.invoke('text') .invoke('text')
.then((text) => { .then((text) => {
const actualDate = text.slice(0, -67); const actualDate = text.slice(0, -67);
// eslint-disable-next-line no-console
console.log(actualDate); console.log(actualDate);
const actualOhlc = text.slice(-67); const actualOhlc = text.slice(-67);
assert.isTrue(expectedDateRegex.test(actualDate)); assert.isTrue(expectedDateRegex.test(actualDate));
@@ -28,16 +28,16 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
it('must be able to select order direction - long/short', function () { it('must be able to select order direction - long/short', function () {
// 7002-SORD-004 // 7002-SORD-004
cy.getByTestId(toggleShort).click().children('input').should('be.checked'); cy.getByTestId(toggleShort).click().next('input').should('be.checked');
cy.getByTestId(toggleLong).click().children('input').should('be.checked'); cy.getByTestId(toggleLong).click().next('input').should('be.checked');
}); });
it('must be able to select order type - limit/market', function () { it('must be able to select order type - limit/market', function () {
// 7002-SORD-005 // 7002-SORD-005
// 7002-SORD-006 // 7002-SORD-006
// 7002-SORD-007 // 7002-SORD-007
cy.getByTestId(toggleLimit).click().children('input').should('be.checked'); cy.getByTestId(toggleLimit).click().next('input').should('be.checked');
cy.getByTestId(toggleMarket).click().children('input').should('be.checked'); cy.getByTestId(toggleMarket).click().next('input').should('be.checked');
}); });
it('order connect vega wallet button should connect', () => { it('order connect vega wallet button should connect', () => {
@@ -51,7 +51,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
.click(); .click();
cy.wait('@walletReq'); cy.wait('@walletReq');
cy.getByTestId(placeOrderBtn).should('be.visible'); cy.getByTestId(placeOrderBtn).should('be.visible');
cy.getByTestId(toggleLimit).children('input').should('be.checked'); cy.getByTestId(toggleLimit).next('input').should('be.checked');
cy.getByTestId(orderPriceField).should('have.value', '101'); cy.getByTestId(orderPriceField).should('have.value', '101');
}); });
}); });
@@ -7,7 +7,7 @@ const closePosition = 'close-position';
const dialogCloseX = 'dialog-close'; const dialogCloseX = 'dialog-close';
const dialogContent = 'dialog-content'; const dialogContent = 'dialog-content';
const dropDownMenu = 'dropdown-menu'; const dropDownMenu = 'dropdown-menu';
const marketActionsContent = 'market-actions-content'; const marketActionsContent = 'position-actions-content';
const positions = 'Positions'; const positions = 'Positions';
const tabPositions = 'tab-positions'; const tabPositions = 'tab-positions';
const toastContent = 'toast-content'; const toastContent = 'toast-content';
@@ -35,6 +35,8 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
cy.setVegaWallet(); cy.setVegaWallet();
cy.visit('/'); cy.visit('/');
cy.getByTestId(manageVegaWallet).click();
cy.getByTestId(walletTransfer).click();
cy.wait('@Assets'); cy.wait('@Assets');
cy.wait('@Accounts'); cy.wait('@Accounts');
@@ -57,7 +59,6 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
// 1003-TRAN-019 // 1003-TRAN-019
cy.getByTestId(transferForm); cy.getByTestId(transferForm);
cy.contains('Enter manually').click(); cy.contains('Enter manually').click();
cy.getByTestId(transferForm) cy.getByTestId(transferForm)
.find(toAddressField) .find(toAddressField)
.type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535'); .type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535');
+4 -1
View File
@@ -1,3 +1,4 @@
import { OrderType } from '@vegaprotocol/types';
import type { OrderSubmission } from '@vegaprotocol/wallet'; import type { OrderSubmission } from '@vegaprotocol/wallet';
const orderSizeField = 'order-size'; const orderSizeField = 'order-size';
@@ -9,7 +10,9 @@ export const createOrder = (order: OrderSubmission): void => {
cy.log('Placing order', order); cy.log('Placing order', order);
const { type, side, size, price, timeInForce, expiresAt } = order; const { type, side, size, price, timeInForce, expiresAt } = order;
cy.getByTestId(`order-type-${type}`).click(); cy.getByTestId(
`order-type-${type === OrderType.TYPE_LIMIT ? 'Limit' : 'Market'}`
).click();
cy.getByTestId(`order-side-${side}`).click(); cy.getByTestId(`order-side-${side}`).click();
cy.getByTestId(orderSizeField).clear().type(size); cy.getByTestId(orderSizeField).clear().type(size);
if (price) { if (price) {
+2 -2
View File
@@ -6,8 +6,8 @@ export const orderTIFDropDown = 'order-tif';
export const placeOrderBtn = 'place-order'; export const placeOrderBtn = 'place-order';
export const toggleShort = 'order-side-SIDE_SELL'; export const toggleShort = 'order-side-SIDE_SELL';
export const toggleLong = 'order-side-SIDE_BUY'; export const toggleLong = 'order-side-SIDE_BUY';
export const toggleLimit = 'order-type-TYPE_LIMIT'; export const toggleLimit = 'order-type-Limit';
export const toggleMarket = 'order-type-TYPE_MARKET'; export const toggleMarket = 'order-type-Market';
export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => { export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => {
return { return {
+1 -1
View File
@@ -16,6 +16,6 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -18,6 +18,6 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.21-core-0.71.6
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.19-core-0.71.6
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://console.fairground.wtf
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
+1 -1
View File
@@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://trading.validators-testnet.vega.rocks
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
@@ -17,7 +17,10 @@ import {
usePaneLayout, usePaneLayout,
} from '../../components/resizable-grid'; } from '../../components/resizable-grid';
import { TradingViews } from './trade-views'; import { TradingViews } from './trade-views';
import { MarketSuccessorBanner } from '../../components/market-banner'; import {
MarketSuccessorBanner,
MarketSuccessorProposalBanner,
} from '../../components/market-banner';
import { FLAGS } from '@vegaprotocol/environment'; import { FLAGS } from '@vegaprotocol/environment';
interface TradeGridProps { interface TradeGridProps {
@@ -127,6 +130,13 @@ const MainGrid = memo(
<TradingViews.orders.component marketId={marketId} /> <TradingViews.orders.component marketId={marketId} />
</VegaWalletContainer> </VegaWalletContainer>
</Tab> </Tab>
{FLAGS.STOP_ORDERS ? (
<Tab id="stop-orders" name={t('Stop orders')}>
<VegaWalletContainer>
<TradingViews.stopOrders.component />
</VegaWalletContainer>
</Tab>
) : null}
<Tab id="fills" name={t('Fills')}> <Tab id="fills" name={t('Fills')}>
<VegaWalletContainer> <VegaWalletContainer>
<TradingViews.fills.component <TradingViews.fills.component
@@ -162,7 +172,12 @@ export const TradeGrid = ({ market, pinnedAsset }: TradeGridProps) => {
return ( return (
<div className={wrapperClasses}> <div className={wrapperClasses}>
<div> <div>
{FLAGS.SUCCESSOR_MARKETS && <MarketSuccessorBanner market={market} />} {FLAGS.SUCCESSOR_MARKETS && (
<>
<MarketSuccessorBanner market={market} />
<MarketSuccessorProposalBanner marketId={market?.id} />
</>
)}
<OracleBanner marketId={market?.id || ''} /> <OracleBanner marketId={market?.id || ''} />
</div> </div>
<div className="min-h-0 p-0.5"> <div className="min-h-0 p-0.5">
@@ -12,7 +12,10 @@ import { Splash } from '@vegaprotocol/ui-toolkit';
import { NO_MARKET } from './constants'; import { NO_MARKET } from './constants';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import classNames from 'classnames'; import classNames from 'classnames';
import { MarketSuccessorBanner } from '../../components/market-banner'; import {
MarketSuccessorBanner,
MarketSuccessorProposalBanner,
} from '../../components/market-banner';
import { FLAGS } from '@vegaprotocol/environment'; import { FLAGS } from '@vegaprotocol/environment';
interface TradePanelsProps { interface TradePanelsProps {
@@ -65,7 +68,12 @@ export const TradePanels = ({
return ( return (
<div className="h-full grid grid-rows-[min-content_1fr_min-content]"> <div className="h-full grid grid-rows-[min-content_1fr_min-content]">
<div> <div>
{FLAGS.SUCCESSOR_MARKETS && <MarketSuccessorBanner market={market} />} {FLAGS.SUCCESSOR_MARKETS && (
<>
<MarketSuccessorBanner market={market} />
<MarketSuccessorProposalBanner marketId={market?.id} />
</>
)}
<OracleBanner marketId={market?.id || ''} /> <OracleBanner marketId={market?.id || ''} />
</div> </div>
<div className="h-full"> <div className="h-full">
@@ -80,9 +88,12 @@ export const TradePanels = ({
<div className="flex flex-nowrap overflow-x-auto max-w-full border-t border-default"> <div className="flex flex-nowrap overflow-x-auto max-w-full border-t border-default">
{Object.keys(TradingViews).map((key) => { {Object.keys(TradingViews).map((key) => {
const isActive = view === key; const isActive = view === key;
const className = classNames('p-4 min-w-[100px] capitalize', { const className = classNames(
'bg-vega-clight-500 dark:bg-vega-cdark-500': isActive, 'py-2 px-4 min-w-[100px] capitalize text-sm',
}); {
'bg-vega-clight-500 dark:bg-vega-cdark-500': isActive,
}
);
return ( return (
<button <button
data-testid={key} data-testid={key}
@@ -12,6 +12,7 @@ import { AccountsContainer } from '../../components/accounts-container';
import { LiquidityContainer } from '../../components/liquidity-container'; import { LiquidityContainer } from '../../components/liquidity-container';
import type { OrderContainerProps } from '../../components/orders-container'; import type { OrderContainerProps } from '../../components/orders-container';
import { OrdersContainer } from '../../components/orders-container'; import { OrdersContainer } from '../../components/orders-container';
import { StopOrdersContainer } from '../../components/stop-orders-container';
type MarketDependantView = type MarketDependantView =
| typeof CandlesChartContainer | typeof CandlesChartContainer
@@ -74,6 +75,10 @@ export const TradingViews = {
label: 'All', label: 'All',
component: OrdersContainer, component: OrdersContainer,
}, },
stopOrders: {
label: 'Stop',
component: StopOrdersContainer,
},
collateral: { label: 'Collateral', component: AccountsContainer }, collateral: { label: 'Collateral', component: AccountsContainer },
fills: { label: 'Fills', component: FillsContainer }, fills: { label: 'Fills', component: FillsContainer },
}; };
+1 -1
View File
@@ -17,7 +17,7 @@ export const Header = ({ title, children }: TradeMarketHeaderProps) => {
); );
return ( return (
<header className={headerClasses}> <header className={headerClasses}>
<div className="flex flex-col justify-center items-start pl-3 lg:pl-4 pt-2 xl:pb-2 pb-0"> <div className="hidden lg:flex flex-col justify-center items-start pl-3 lg:pl-4 pt-2 xl:pb-2 pb-0">
{title} {title}
</div> </div>
<div data-testid="header-summary" className="min-w-0"> <div data-testid="header-summary" className="min-w-0">
+3 -1
View File
@@ -1,10 +1,12 @@
import classNames from 'classnames';
export const WalletIcon = ({ className }: { className?: string }) => { export const WalletIcon = ({ className }: { className?: string }) => {
return ( return (
<svg <svg
width="26" width="26"
height="18" height="18"
viewBox="0 0 26 18" viewBox="0 0 26 18"
className={className} className={classNames('fill-current', className)}
data-testid="wallet-icon" data-testid="wallet-icon"
> >
<path d="M4.77437 17.7499H4.74987C3.6504 17.7368 2.77439 16.8489 2.77439 15.7772V12.8495V12.6343L2.5615 12.6023C1.59672 12.4575 0.849609 11.6116 0.849609 10.6266V7.40064C0.849609 6.39018 1.59509 5.56985 2.56147 5.4249L2.77439 5.39297V5.17767V2.24998C2.77439 1.14102 3.66537 0.25 4.77437 0.25H23.7501C24.8591 0.25 25.7501 1.14098 25.7501 2.24998V15.7499C25.7501 16.8588 24.8591 17.7499 23.7501 17.7499H4.77437ZM4.44917 12.5992H4.19917L4.77441 16.075V16.325H4.77466H23.7502C24.0778 16.325 24.3254 16.0777 24.3254 15.7497V2.24984C24.3254 1.9222 24.0782 1.6746 23.7502 1.6746H4.77441C4.44677 1.6746 4.19917 1.92182 4.19917 2.24984V5.12306V5.37306H4.44917H7.0244C8.51139 5.37306 9.67508 6.56094 9.67508 8.02374V9.94852C9.67508 11.4355 8.4872 12.5992 7.0244 12.5992H4.44917ZM2.84963 6.8253C2.52199 6.8253 2.27439 7.07253 2.27439 7.40054V10.6264C2.27439 10.9541 2.52161 11.2017 2.84962 11.2017L7.02419 11.2019C7.73619 11.2019 8.25009 10.6515 8.25009 9.97598V8.0512C8.25009 7.3392 7.69976 6.8253 7.0242 6.8253H2.84963Z" /> <path d="M4.77437 17.7499H4.74987C3.6504 17.7368 2.77439 16.8489 2.77439 15.7772V12.8495V12.6343L2.5615 12.6023C1.59672 12.4575 0.849609 11.6116 0.849609 10.6266V7.40064C0.849609 6.39018 1.59509 5.56985 2.56147 5.4249L2.77439 5.39297V5.17767V2.24998C2.77439 1.14102 3.66537 0.25 4.77437 0.25H23.7501C24.8591 0.25 25.7501 1.14098 25.7501 2.24998V15.7499C25.7501 16.8588 24.8591 17.7499 23.7501 17.7499H4.77437ZM4.44917 12.5992H4.19917L4.77441 16.075V16.325H4.77466H23.7502C24.0778 16.325 24.3254 16.0777 24.3254 15.7497V2.24984C24.3254 1.9222 24.0782 1.6746 23.7502 1.6746H4.77441C4.44677 1.6746 4.19917 1.92182 4.19917 2.24984V5.12306V5.37306H4.44917H7.0244C8.51139 5.37306 9.67508 6.56094 9.67508 8.02374V9.94852C9.67508 11.4355 8.4872 12.5992 7.0244 12.5992H4.44917ZM2.84963 6.8253C2.52199 6.8253 2.27439 7.07253 2.27439 7.40054V10.6264C2.27439 10.9541 2.52161 11.2017 2.84962 11.2017L7.02419 11.2019C7.73619 11.2019 8.25009 10.6515 8.25009 9.97598V8.0512C8.25009 7.3392 7.69976 6.8253 7.0242 6.8253H2.84963Z" />
@@ -11,9 +11,9 @@ export const LayoutWithSidebar = () => {
const gridClasses = classNames( const gridClasses = classNames(
'h-full relative z-0 grid', 'h-full relative z-0 grid',
'grid-rows-[min-content_1fr]', 'grid-rows-[min-content_1fr_40px]',
'grid-cols-[1fr_45px]', 'lg:grid-rows-[min-content_1fr]',
'lg:grid-cols-[1fr_350px_45px]' 'lg:grid-cols-[1fr_350px_40px]'
); );
return ( return (
@@ -40,7 +40,14 @@ export const LayoutWithSidebar = () => {
> >
<SidebarContent /> <SidebarContent />
</aside> </aside>
<div className="col-start-2 lg:col-start-3 bg-vega-clight-800 dark:bg-vega-cdark-800 border-l border-default"> <div
className={classNames(
'bg-vega-clight-800 dark:bg-vega-cdark-800',
'border-t lg:border-l lg:border-t-0 border-default',
'row-start-3 col-start-1 cols-span-full',
'lg:row-start-2 lg:row-span-full lg:col-start-3'
)}
>
<Sidebar /> <Sidebar />
</div> </div>
</div> </div>
@@ -42,8 +42,6 @@ export const LiquidityHeader = () => {
triggeringRatio, triggeringRatio,
}); });
console.log(market);
return ( return (
<Header <Header
title={ title={
@@ -1 +1,2 @@
export * from './market-successor-banner'; export * from './market-successor-banner';
export * from './market-successor-proposal-banner';
@@ -0,0 +1,155 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import type { SingleExecutionResult } from '@apollo/client';
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import { MarketSuccessorProposalBanner } from './market-successor-proposal-banner';
import type { SuccessorProposalsListQuery } from '@vegaprotocol/proposals';
import { SuccessorProposalsListDocument } from '@vegaprotocol/proposals';
const marketProposalMock: MockedResponse<SuccessorProposalsListQuery> = {
request: {
query: SuccessorProposalsListDocument,
},
result: {
data: {
proposalsConnection: {
edges: [
{
node: {
__typename: 'Proposal',
id: 'proposal-1',
terms: {
__typename: 'ProposalTerms',
change: {
__typename: 'NewMarket',
instrument: {
name: 'New proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
},
},
],
},
},
},
};
describe('MarketSuccessorProposalBanner', () => {
it('should display single proposal', async () => {
render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
await waitFor(() => {
expect(
screen.getByText('A successors to this market has been proposed')
).toBeInTheDocument();
});
expect(
screen
.getByRole('link')
.getAttribute('href')
?.endsWith('/proposals/proposal-1') ?? false
).toBe(true);
});
it('should display plural proposals', async () => {
const dualProposalMock = {
...marketProposalMock,
result: {
...marketProposalMock.result,
data: {
proposalsConnection: {
edges: [
...((
marketProposalMock?.result as SingleExecutionResult<SuccessorProposalsListQuery>
)?.data?.proposalsConnection?.edges ?? []),
{
node: {
__typename: 'Proposal',
id: 'proposal-2',
terms: {
__typename: 'ProposalTerms',
change: {
__typename: 'NewMarket',
instrument: {
name: 'New second proposal of the market successor',
},
successorConfiguration: {
parentMarketId: 'marketId',
},
},
},
},
},
],
},
},
},
};
render(
<MockedProvider mocks={[dualProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
await waitFor(() => {
expect(
screen.getByText('Successors to this market have been proposed')
).toBeInTheDocument();
});
expect(
screen
.getAllByRole('link')[0]
.getAttribute('href')
?.endsWith('/proposals/proposal-1') ?? false
).toBe(true);
expect(
screen
.getAllByRole('link')[1]
.getAttribute('href')
?.endsWith('/proposals/proposal-2') ?? false
).toBe(true);
});
it('banner should be hidden because no proposals', () => {
const { container } = render(
<MockedProvider>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
expect(container).toBeEmptyDOMElement();
});
it('banner should be hidden because no proposals for the market', () => {
const { container } = render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="otherMarketId" />
</MockedProvider>
);
expect(container).toBeEmptyDOMElement();
});
it('banner should be hidden after user close click', async () => {
const { container } = render(
<MockedProvider mocks={[marketProposalMock]}>
<MarketSuccessorProposalBanner marketId="marketId" />
</MockedProvider>
);
await waitFor(() => {
expect(
screen.getByText('A successors to this market has been proposed')
).toBeInTheDocument();
});
await act(() => {
screen.getByTestId('notification-banner-close').click();
});
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
});
});
});
@@ -0,0 +1,71 @@
import { useState } from 'react';
import type {
SuccessorProposalListFieldsFragment,
NewMarketSuccessorFieldsFragment,
} from '@vegaprotocol/proposals';
import { useSuccessorProposalsListQuery } from '@vegaprotocol/proposals';
import {
ExternalLink,
Intent,
NotificationBanner,
} from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
import { DApp, TOKEN_PROPOSAL, useLinks } from '@vegaprotocol/environment';
export const MarketSuccessorProposalBanner = ({
marketId,
}: {
marketId?: string;
}) => {
const { data: proposals } = useSuccessorProposalsListQuery({
skip: !marketId,
});
const successors =
proposals?.proposalsConnection?.edges
?.map((item) => item?.node as SuccessorProposalListFieldsFragment)
.filter(
(item: SuccessorProposalListFieldsFragment) =>
(item.terms?.change as NewMarketSuccessorFieldsFragment)
?.successorConfiguration?.parentMarketId === marketId
) ?? [];
const [visible, setVisible] = useState(true);
const tokenLink = useLinks(DApp.Token);
if (visible && successors.length) {
return (
<NotificationBanner
intent={Intent.Primary}
onClose={() => {
setVisible(false);
}}
>
<div className="uppercase mb-1">
{successors.length === 1
? t('A successors to this market has been proposed')
: t('Successors to this market have been proposed')}
</div>
<div>
{successors.length === 1
? t('Check out the terms of the proposal and vote:')
: t('Check out the terms of the proposals and vote:')}{' '}
{successors.map((item, i) => {
const externalLink = tokenLink(
TOKEN_PROPOSAL.replace(':id', item.id || '')
);
return (
<>
<ExternalLink href={externalLink} key={i}>
{
(item.terms?.change as NewMarketSuccessorFieldsFragment)
?.instrument.name
}
</ExternalLink>
{i < successors.length - 1 && ', '}
</>
);
})}
</div>
</NotificationBanner>
);
}
return null;
};
@@ -4,10 +4,12 @@ import { useParams } from 'react-router-dom';
import { MarketSelector } from '../../components/market-selector/market-selector'; import { MarketSelector } from '../../components/market-selector/market-selector';
import { MarketHeaderStats } from '../../client-pages/market/market-header-stats'; import { MarketHeaderStats } from '../../client-pages/market/market-header-stats';
import { useMarket } from '@vegaprotocol/markets'; import { useMarket } from '@vegaprotocol/markets';
import { useState } from 'react';
export const MarketHeader = () => { export const MarketHeader = () => {
const { marketId } = useParams(); const { marketId } = useParams();
const { data } = useMarket(marketId); const { data } = useMarket(marketId);
const [open, setOpen] = useState(false);
if (!data) return null; if (!data) return null;
@@ -15,6 +17,8 @@ export const MarketHeader = () => {
<Header <Header
title={ title={
<Popover <Popover
open={open}
onChange={setOpen}
trigger={ trigger={
<HeaderTitle> <HeaderTitle>
{data.tradableInstrument.instrument.code} {data.tradableInstrument.instrument.code}
@@ -23,7 +27,10 @@ export const MarketHeader = () => {
} }
alignOffset={-10} alignOffset={-10}
> >
<MarketSelector currentMarketId={marketId} /> <MarketSelector
currentMarketId={marketId}
onSelect={() => setOpen(false)}
/>
</Popover> </Popover>
} }
> >
@@ -98,6 +98,7 @@ describe('MarketSelectorItem', () => {
market={market} market={market}
currentMarketId={market.id} currentMarketId={market.id}
style={{}} style={{}}
onSelect={jest.fn()}
/> />
</MockedProvider> </MockedProvider>
</MemoryRouter> </MemoryRouter>
@@ -17,10 +17,12 @@ export const MarketSelectorItem = ({
market, market,
style, style,
currentMarketId, currentMarketId,
onSelect,
}: { }: {
market: MarketMaybeWithDataAndCandles; market: MarketMaybeWithDataAndCandles;
style: CSSProperties; style: CSSProperties;
currentMarketId?: string; currentMarketId?: string;
onSelect: (marketId: string) => void;
}) => { }) => {
return ( return (
<div style={style} role="row"> <div style={style} role="row">
@@ -32,6 +34,7 @@ export const MarketSelectorItem = ({
'bg-vega-clight-600 dark:bg-vega-cdark-600': 'bg-vega-clight-600 dark:bg-vega-cdark-600':
market.id === currentMarketId, market.id === currentMarketId,
})} })}
onClick={() => onSelect(market.id)}
> >
<MarketData market={market} /> <MarketData market={market} />
</Link> </Link>
@@ -80,7 +83,7 @@ const MarketData = ({ market }: { market: MarketMaybeWithDataAndCandles }) => {
return ( return (
<> <>
<div className="w-2/5" role="gridcell"> <div className="w-2/5" role="gridcell">
<h3 className="text-ellipsis whitespace-nowrap overflow-hidden"> <h3 className="text-ellipsis text-sm lg:text-base whitespace-nowrap overflow-hidden">
{market.tradableInstrument.instrument.code} {market.tradableInstrument.instrument.code}
</h3> </h3>
{mode && ( {mode && (
@@ -90,7 +93,7 @@ const MarketData = ({ market }: { market: MarketMaybeWithDataAndCandles }) => {
)} )}
</div> </div>
<div <div
className="w-1/5 text-sm whitespace-nowrap text-ellipsis overflow-hidden" className="w-1/5 text-xs lg:text-sm whitespace-nowrap text-ellipsis overflow-hidden"
title={instrument.product.settlementAsset.symbol} title={instrument.product.settlementAsset.symbol}
data-testid="market-selector-price" data-testid="market-selector-price"
role="gridcell" role="gridcell"
@@ -98,7 +101,7 @@ const MarketData = ({ market }: { market: MarketMaybeWithDataAndCandles }) => {
{price} {instrument.product.settlementAsset.symbol} {price} {instrument.product.settlementAsset.symbol}
</div> </div>
<div <div
className="w-1/5 text-sm text-right whitespace-nowrap text-ellipsis overflow-hidden" className="w-1/5 text-xs lg:text-sm text-right whitespace-nowrap text-ellipsis overflow-hidden"
title={t('24h vol')} title={t('24h vol')}
data-testid="market-selector-volume" data-testid="market-selector-volume"
role="gridcell" role="gridcell"
@@ -137,7 +137,7 @@ describe('MarketSelector', () => {
it('renders only active markets', () => { it('renders only active markets', () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
expect(screen.getAllByTestId(/market-\d/)).toHaveLength( expect(screen.getAllByTestId(/market-\d/)).toHaveLength(
@@ -148,7 +148,7 @@ describe('MarketSelector', () => {
it('filters by product type', async () => { it('filters by product type', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -174,7 +174,7 @@ describe('MarketSelector', () => {
it('filters by search term', async () => { it('filters by search term', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -202,7 +202,7 @@ describe('MarketSelector', () => {
it('filters by asset', async () => { it('filters by asset', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -234,7 +234,7 @@ describe('MarketSelector', () => {
it('sorts by gained', async () => { it('sorts by gained', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -256,7 +256,7 @@ describe('MarketSelector', () => {
it('sorts by lost', async () => { it('sorts by lost', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -272,7 +272,7 @@ describe('MarketSelector', () => {
it('sorts by new', async () => { it('sorts by new', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MarketSelector currentMarketId="market-0" /> <MarketSelector currentMarketId="market-0" onSelect={jest.fn()} />
</MemoryRouter> </MemoryRouter>
); );
@@ -35,7 +35,7 @@ export const MarketSelector = ({
onSelect, onSelect,
}: { }: {
currentMarketId?: string; currentMarketId?: string;
onSelect?: (marketId: string) => void; onSelect: (marketId: string) => void;
}) => { }) => {
const [filter, setFilter] = useState<Filter>({ const [filter, setFilter] = useState<Filter>({
searchTerm: '', searchTerm: '',
@@ -48,7 +48,7 @@ export const MarketSelector = ({
return ( return (
<div data-testid="market-selector"> <div data-testid="market-selector">
<div className="pt-2 px-2 mb-2 w-[320px] lg:w-[584px]"> <div className="pt-2 px-2 mb-2">
<ProductSelector <ProductSelector
product={filter.product} product={filter.product}
onSelect={(product) => { onSelect={(product) => {
@@ -147,16 +147,17 @@ const MarketList = ({
loading: boolean; loading: boolean;
searchTerm: string; searchTerm: string;
currentMarketId?: string; currentMarketId?: string;
onSelect?: (marketId: string) => void; onSelect: (marketId: string) => void;
noItems: string; noItems: string;
}) => { }) => {
const itemSize = 45; const itemSize = 45;
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const rect = listRef.current?.getBoundingClientRect(); const rect = listRef.current?.getBoundingClientRect();
// allow virtualized list to grow until it runs out of space // allow virtualized list to grow until it runs out of space
const height = rect const computedHeight = rect
? Math.min(data.length * itemSize, window.innerHeight - rect.y) ? Math.min(data.length * itemSize, window.innerHeight - rect.y)
: 400; : 400;
const height = Math.max(computedHeight, 45);
if (error) { if (error) {
return <div>{error.message}</div>; return <div>{error.message}</div>;
@@ -199,7 +200,7 @@ const MarketList = ({
interface ListItemData { interface ListItemData {
data: MarketMaybeWithDataAndCandles[]; data: MarketMaybeWithDataAndCandles[];
onSelect?: (marketId: string) => void; onSelect: (marketId: string) => void;
currentMarketId?: string; currentMarketId?: string;
} }
@@ -216,6 +217,7 @@ const ListItem = ({
market={data.data[index]} market={data.data[index]}
currentMarketId={data.currentMarketId} currentMarketId={data.currentMarketId}
style={style} style={style}
onSelect={data.onSelect}
/> />
); );
@@ -252,7 +254,11 @@ const List = ({
if (!data.length) { if (!data.length) {
return ( return (
<div style={{ height }} data-testid="no-items"> <div
style={{ height }}
className="flex items-center"
data-testid="no-items"
>
<div className="mx-4 my-2 text-sm">{noItems}</div> <div className="mx-4 my-2 text-sm">{noItems}</div>
</div> </div>
); );
+1
View File
@@ -1 +1,2 @@
export * from './navbar'; export * from './navbar';
export * from './nav-header';
@@ -0,0 +1,66 @@
import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { MarketSelector } from '../market-selector';
import { useMarket } from '@vegaprotocol/markets';
import { t } from '@vegaprotocol/i18n';
import { useParams } from 'react-router-dom';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { useState } from 'react';
/**
* This is only rendered for the mobile navigation
*/
export const NavHeader = () => {
const { marketId } = useParams();
const { data } = useMarket(marketId);
const [open, setOpen] = useState(false);
if (!marketId) return null;
return (
<FullScreenPopover
open={open}
onOpenChange={(x) => {
setOpen(x);
}}
trigger={
<h1 className="flex gap-1 sm:gap-2 md:gap-4 items-center text-default text-lg whitespace-nowrap xl:pr-4 xl:border-r border-default">
{data ? data.tradableInstrument.instrument.code : t('Select market')}
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={20} />
</h1>
}
>
<MarketSelector
currentMarketId={marketId}
onSelect={() => setOpen(false)}
/>
</FullScreenPopover>
);
};
export interface PopoverProps extends PopoverPrimitive.PopoverProps {
trigger: React.ReactNode | string;
}
export const FullScreenPopover = ({
trigger,
children,
open,
onOpenChange,
}: PopoverProps) => {
return (
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
<PopoverPrimitive.Trigger data-testid="popover-trigger">
{trigger}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-testid="popover-content"
className="w-screen bg-vega-clight-800 dark:bg-vega-cdark-800 text-default border border-default"
sideOffset={5}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
};
+145 -29
View File
@@ -1,42 +1,158 @@
import { render, screen } from '@testing-library/react'; import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet';
import { Navbar } from './navbar'; import { Navbar } from './navbar';
import { useGlobalStore } from '../../stores';
jest.mock('@vegaprotocol/proposals', () => ({
ProtocolUpgradeCountdown: () => null,
}));
describe('Navbar', () => { describe('Navbar', () => {
const pubKey = 'pubKey'; const pubKey = '000';
it('should be properly rendered', () => { const pubKeys = [
render( {
<MockedProvider> publicKey: pubKey,
<MemoryRouter> name: 'Pub key 0',
<VegaWalletContext.Provider },
value={{ pubKey } as VegaWalletContextShape} {
> publicKey: '111',
<Navbar theme="dark" /> name: 'Pub key 1',
</VegaWalletContext.Provider> },
</MemoryRouter> ];
</MockedProvider> const marketId = 'abc';
const navbarContent = 'navbar-menu-content';
const renderComponent = (
initialEntries?: string[],
walletContext?: Partial<VegaWalletContextShape>
) => {
const context = {
pubKey,
pubKeys,
selectPubKey: jest.fn(),
disconnect: jest.fn(),
...walletContext,
} as VegaWalletContextShape;
return render(
<MemoryRouter initialEntries={initialEntries}>
<VegaWalletContext.Provider value={context}>
<Navbar />
</VegaWalletContext.Provider>
</MemoryRouter>
); );
expect(screen.getByTestId('Markets')).toBeInTheDocument(); };
expect(screen.getByTestId('Trading')).toBeInTheDocument();
expect(screen.getByTestId('Portfolio')).toBeInTheDocument(); beforeAll(() => {
useGlobalStore.setState({ marketId });
});
it('should be properly rendered', () => {
renderComponent();
const expectedLinks = [
['/', ''],
['/markets/all', 'Markets'],
[`/markets/${marketId}`, 'Trading'],
['/portfolio', 'Portfolio'],
];
const links = screen.getAllByRole('link');
links.forEach((link, i) => {
const [href, text] = expectedLinks[i];
expect(link).toHaveAttribute('href', href);
expect(link).toHaveTextContent(text);
});
}); });
it('Markets page route should not match empty market page', () => { it('Markets page route should not match empty market page', () => {
render( renderComponent(['/markets/all']);
<MockedProvider> expect(screen.getByRole('link', { name: 'Markets' })).toHaveClass('active');
<MemoryRouter initialEntries={['/markets/all']}> expect(screen.getByRole('link', { name: 'Trading' })).not.toHaveClass(
<VegaWalletContext.Provider 'active'
value={{ pubKey } as VegaWalletContextShape}
>
<Navbar theme="dark" />
</VegaWalletContext.Provider>
</MemoryRouter>
</MockedProvider>
); );
expect(screen.getByTestId('Markets')).toHaveClass('active'); });
expect(screen.getByTestId('Trading')).not.toHaveClass('active');
it('can open menu and navigate on small screens', async () => {
renderComponent();
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
const menuEl = screen.getByTestId(navbarContent);
expect(menuEl).toBeInTheDocument();
const menu = within(menuEl);
const expectedLinks = [
['/markets/all', 'Markets'],
[`/markets/${marketId}`, 'Trading'],
['/portfolio', 'Portfolio'],
];
const links = menu.getAllByRole('link');
links.forEach((link, i) => {
const [href, text] = expectedLinks[i];
expect(link).toHaveAttribute('href', href);
expect(link).toHaveTextContent(text);
});
await userEvent.click(screen.getByRole('button', { name: 'Close menu' }));
expect(screen.queryByTestId(navbarContent)).not.toBeInTheDocument();
});
it('can close menu by clicking overlay', async () => {
renderComponent();
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByTestId(navbarContent)).toBeInTheDocument();
await userEvent.click(screen.getByTestId('navbar-menu-overlay'));
expect(screen.queryByTestId(navbarContent)).not.toBeInTheDocument();
});
it('can open wallet menu on small screens and change pubkey', async () => {
const mockSelectPubKey = jest.fn();
renderComponent(undefined, { selectPubKey: mockSelectPubKey });
await userEvent.click(screen.getByRole('button', { name: 'Wallet' }));
const menuEl = screen.getByTestId(navbarContent);
expect(menuEl).toBeInTheDocument();
const menu = within(menuEl);
expect(menu.getAllByTestId(/key-\d+-mobile/)).toHaveLength(pubKeys.length);
const activeKey = within(menu.getByTestId('key-000-mobile'));
expect(activeKey.getByText(pubKeys[0].name)).toBeInTheDocument();
expect(activeKey.getByTestId('icon-tick')).toBeInTheDocument();
const inactiveKey = within(menu.getByTestId('key-111-mobile'));
await userEvent.click(inactiveKey.getByText(pubKeys[1].name));
expect(mockSelectPubKey).toHaveBeenCalledWith(pubKeys[1].publicKey);
});
it('can transfer and close menu', async () => {
renderComponent();
await userEvent.click(screen.getByRole('button', { name: 'Wallet' }));
const menuEl = screen.getByTestId(navbarContent);
expect(menuEl).toBeInTheDocument();
const menu = within(menuEl);
await userEvent.click(menu.getByText('Transfer'));
expect(screen.queryByTestId(navbarContent)).not.toBeInTheDocument();
});
it('can disconnect and close menu', async () => {
const mockDisconnect = jest.fn();
renderComponent(undefined, { disconnect: mockDisconnect });
await userEvent.click(screen.getByRole('button', { name: 'Wallet' }));
const menuEl = screen.getByTestId(navbarContent);
expect(menuEl).toBeInTheDocument();
const menu = within(menuEl);
await userEvent.click(menu.getByText('Disconnect'));
expect(mockDisconnect).toHaveBeenCalled();
expect(screen.queryByTestId(navbarContent)).not.toBeInTheDocument();
}); });
}); });
+369 -109
View File
@@ -1,140 +1,400 @@
import type { ComponentProps, ReactNode } from 'react'; import type { ButtonHTMLAttributes, LiHTMLAttributes, ReactNode } from 'react';
import { import { useState } from 'react';
DApp, import { useEnvironment, DocsLinks, Networks } from '@vegaprotocol/environment';
NetworkSwitcher,
TOKEN_GOVERNANCE,
useEnvironment,
useLinks,
DocsLinks,
} from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useGlobalStore } from '../../stores'; import { useGlobalStore } from '../../stores';
import { VegaWalletConnectButton } from '../vega-wallet-connect-button'; import { VegaWalletConnectButton } from '../vega-wallet-connect-button';
import { import { VegaIconNames, VegaIcon, VLogo } from '@vegaprotocol/ui-toolkit';
Navigation, import * as N from '@radix-ui/react-navigation-menu';
NavigationList, import * as D from '@radix-ui/react-dialog';
NavigationItem, import { NavLink } from 'react-router-dom';
NavigationLink,
ExternalLink,
NavigationBreakpoint,
NavigationTrigger,
NavigationContent,
VegaIconNames,
VegaIcon,
} from '@vegaprotocol/ui-toolkit';
import { Links, Routes } from '../../pages/client-router'; import { Links, Routes } from '../../pages/client-router';
import { import classNames from 'classnames';
ProtocolUpgradeCountdown, import { VegaWalletMenu } from '../vega-wallet';
ProtocolUpgradeCountdownMode, import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
} from '@vegaprotocol/proposals'; import { WalletIcon } from '../icons/wallet';
import { ProtocolUpgradeCountdown } from '@vegaprotocol/proposals';
type MenuState = 'wallet' | 'nav' | null;
type Theme = 'system' | 'yellow';
export const Navbar = ({ export const Navbar = ({
children,
theme = 'system', theme = 'system',
}: { }: {
theme: ComponentProps<typeof Navigation>['theme']; children?: ReactNode;
theme?: Theme;
}) => { }) => {
const { GITHUB_FEEDBACK_URL } = useEnvironment(); // menu state for small screens
const tokenLink = useLinks(DApp.Token); const [menu, setMenu] = useState<MenuState>(null);
const { pubKey } = useVegaWallet();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const isConnected = pubKey !== null;
const navTextClasses = 'text-vega-clight-200 dark:text-vega-cdark-200';
const rootClasses = classNames(
navTextClasses,
'flex gap-3 h-10 pr-1',
'border-b border-default',
'bg-vega-clight-800 dark:bg-vega-cdark-800'
);
return (
<N.Root className={rootClasses}>
<NavLink
to="/"
className={classNames('flex items-center px-3', {
'bg-vega-yellow text-vega-clight-50': theme === 'yellow',
'text-default': theme === 'system',
})}
>
<VLogo className="w-4" />
</NavLink>
{/* Left section */}
<div className="lg:hidden flex items-center">{children}</div>
{/* Used to show header in nav on mobile */}
<div className="hidden lg:block">
<NavbarMenu onClick={() => setMenu(null)} />
</div>
{/* Right section */}
<div className="ml-auto flex justify-end items-center gap-2">
<ProtocolUpgradeCountdown />
<NavbarMobileButton
onClick={() => {
if (isConnected) {
setMenu((x) => (x === 'wallet' ? null : 'wallet'));
} else {
openVegaWalletDialog();
}
}}
data-testid="navbar-mobile-wallet"
>
<span className="sr-only">{t('Wallet')}</span>
<WalletIcon className="w-6" />
</NavbarMobileButton>
<NavbarMobileButton
onClick={() => {
setMenu((x) => (x === 'nav' ? null : 'nav'));
}}
data-testid="navbar-mobile-burger"
>
<span className="sr-only">{t('Menu')}</span>
<BurgerIcon />
</NavbarMobileButton>
<div className="hidden lg:block">
<VegaWalletConnectButton />
</div>
</div>
{menu !== null && (
<D.Root
open={menu !== null}
onOpenChange={(open) => setMenu((x) => (open ? x : null))}
>
<D.Overlay
className="lg:hidden fixed inset-0 dark:bg-black/80 bg-black/50 z-20"
data-testid="navbar-menu-overlay"
/>
<D.Content
className={classNames(
'lg:hidden',
'fixed top-0 right-0 z-20 w-3/4 h-screen border-l border-default bg-vega-clight-700 dark:bg-vega-cdark-700',
navTextClasses
)}
data-testid="navbar-menu-content"
>
<div className="flex justify-end items-center h-10 p-1">
<NavbarMobileButton onClick={() => setMenu(null)}>
<span className="sr-only">{t('Close menu')}</span>
<VegaIcon name={VegaIconNames.CROSS} size={24} />
</NavbarMobileButton>
</div>
{menu === 'nav' && <NavbarMenu onClick={() => setMenu(null)} />}
{menu === 'wallet' && <VegaWalletMenu setMenu={setMenu} />}
</D.Content>
</D.Root>
)}
</N.Root>
);
};
/**
* List of links or dropdown triggers to show in the main section
* of the navigation
*/
const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
const { VEGA_ENV, VEGA_NETWORKS, GITHUB_FEEDBACK_URL } = useEnvironment();
const marketId = useGlobalStore((store) => store.marketId); const marketId = useGlobalStore((store) => store.marketId);
// If we have a stored marketId make Trade link go to that market
// otherwise always go to /markets/all
const tradingPath = marketId const tradingPath = marketId
? Links[Routes.MARKET](marketId) ? Links[Routes.MARKET](marketId)
: Links[Routes.MARKET](); : Links[Routes.MARKET]('');
return ( return (
<Navigation <div className="lg:flex lg:h-full gap-3">
appName="console" <NavbarList>
theme={theme} <NavbarItem>
actions={ <NavbarTrigger data-testid="navbar-network-switcher-trigger">
<> {envNameMapping[VEGA_ENV]}
<ProtocolUpgradeCountdown </NavbarTrigger>
mode={ProtocolUpgradeCountdownMode.IN_ESTIMATED_TIME_REMAINING} <NavbarContent data-testid="navbar-content-network-switcher">
/> <ul className="lg:p-4">
<VegaWalletConnectButton /> {[Networks.MAINNET, Networks.TESTNET].map((n) => {
</> const url = VEGA_NETWORKS[n];
} if (!url) return;
breakpoints={[521, 1122]} return (
> <NavbarSubItem key={n}>
<NavigationList <NavbarLink to={url}>{envNameMapping[n]}</NavbarLink>
className="[.drawer-content_&]:border-b [.drawer-content_&]:border-b-vega-light-200 dark:[.drawer-content_&]:border-b-vega-dark-200 [.drawer-content_&]:pb-8 [.drawer-content_&]:mb-2" </NavbarSubItem>
hide={[NavigationBreakpoint.Small]} );
> })}
<NavigationItem className="[.drawer-content_&]:w-full"> </ul>
<NetworkSwitcher className="[.drawer-content_&]:w-full" /> </NavbarContent>
</NavigationItem> </NavbarItem>
</NavigationList> </NavbarList>
<NavigationList <NavbarListDivider />
hide={[NavigationBreakpoint.Narrow, NavigationBreakpoint.Small]} <NavbarList>
> <NavbarItem>
<NavigationItem> <NavbarLink to={Links[Routes.MARKETS]()} onClick={onClick}>
<NavigationLink data-testid="Markets" to={Links[Routes.MARKETS]()}>
{t('Markets')} {t('Markets')}
</NavigationLink> </NavbarLink>
</NavigationItem> </NavbarItem>
<NavigationItem> <NavbarItem>
<NavigationLink data-testid="Trading" to={tradingPath} end> <NavbarLink to={tradingPath} onClick={onClick}>
{t('Trading')} {t('Trading')}
</NavigationLink> </NavbarLink>
</NavigationItem> </NavbarItem>
<NavigationItem> <NavbarItem>
<NavigationLink <NavbarLink to={Links[Routes.PORTFOLIO]()} onClick={onClick}>
data-testid="Portfolio"
to={Links[Routes.PORTFOLIO]()}
>
{t('Portfolio')} {t('Portfolio')}
</NavigationLink> </NavbarLink>
</NavigationItem> </NavbarItem>
<NavigationItem> <NavbarItem>
<NavExternalLink href={tokenLink(TOKEN_GOVERNANCE)}> <NavbarTrigger>{t('Resources')}</NavbarTrigger>
{t('Governance')} <NavbarContent data-testid="navbar-content-resources">
</NavExternalLink> <ul className="lg:p-4">
</NavigationItem> {DocsLinks?.NEW_TO_VEGA && (
{DocsLinks?.NEW_TO_VEGA && GITHUB_FEEDBACK_URL && ( <NavbarSubItem>
<NavigationItem> <NavbarLinkExternal to={DocsLinks?.NEW_TO_VEGA}>
<NavigationTrigger>{t('Resources')}</NavigationTrigger>
<NavigationContent>
<NavigationList>
<NavigationItem>
<NavExternalLink href={DocsLinks.NEW_TO_VEGA}>
{t('Docs')} {t('Docs')}
</NavExternalLink> </NavbarLinkExternal>
</NavigationItem> </NavbarSubItem>
<NavigationItem> )}
<NavExternalLink href={GITHUB_FEEDBACK_URL}> {GITHUB_FEEDBACK_URL && (
<NavbarSubItem>
<NavbarLinkExternal to={GITHUB_FEEDBACK_URL}>
{t('Give Feedback')} {t('Give Feedback')}
</NavExternalLink> </NavbarLinkExternal>
</NavigationItem> </NavbarSubItem>
<NavigationItem> )}
<NavigationLink <NavbarSubItem>
data-testid="Disclaimer" <NavbarLink to={Links[Routes.DISCLAIMER]()} onClick={onClick}>
to={Links[Routes.DISCLAIMER]()} {t('Disclaimer')}
> </NavbarLink>
{t('Disclaimer')} </NavbarSubItem>
</NavigationLink> </ul>
</NavigationItem> </NavbarContent>
</NavigationList> </NavbarItem>
</NavigationContent> </NavbarList>
</NavigationItem> </div>
)}
</NavigationList>
</Navigation>
); );
}; };
const NavExternalLink = ({ /**
* Wrapper for radix-ux Trigger for consistent styles
*/
const NavbarTrigger = ({
children, children,
href, ...props
}: N.NavigationMenuTriggerProps) => {
return (
<N.Trigger
{...props}
onPointerMove={preventHover}
onPointerLeave={preventHover}
className={classNames(
'w-full lg:w-auto lg:h-full',
'flex items-center justify-between lg:justify-center gap-2 px-6 py-2 lg:p-0',
'text-lg lg:text-sm',
'hover:text-vega-clight-100 dark:hover:text-vega-cdark-100'
)}
>
{children}
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={14} />
</N.Trigger>
);
};
/**
* Wrapper for react-router-dom NavLink for consistent styles
*/
const NavbarLink = ({
children,
to,
onClick,
}: { }: {
children: ReactNode; children: ReactNode;
href: string; to: string;
onClick?: () => void;
}) => { }) => {
return ( return (
<ExternalLink href={href}> <N.Link asChild={true}>
<span className="flex items-center gap-2"> <NavLink
<span>{children}</span> to={to}
<VegaIcon name={VegaIconNames.OPEN_EXTERNAL} /> className={classNames(
</span> 'block lg:flex lg:h-full flex-col justify-center',
</ExternalLink> 'px-6 py-2 lg:p-0 text-lg lg:text-sm',
'hover:text-vega-clight-100 dark:hover:text-vega-cdark-100'
)}
onClick={onClick}
>
{({ isActive }) => {
const borderClasses = {
'border-b-2': true,
'border-transparent': !isActive,
'border-vega-yellow lg:group-[.navbar-content]:border-transparent':
isActive,
};
return (
<>
<span
className={classNames('lg:border-0', borderClasses, {
'text-vega-clight-50 dark:text-vega-cdark-50': isActive,
})}
>
{children}
</span>
<span
className={classNames(
'hidden lg:block absolute left-0 bottom-0 w-full h-0',
borderClasses
)}
/>
</>
);
}}
</NavLink>
</N.Link>
); );
}; };
const NavbarItem = (props: N.NavigationMenuItemProps) => {
return <N.Item {...props} className="relative" />;
};
const NavbarSubItem = (props: LiHTMLAttributes<HTMLElement>) => {
return <li {...props} className="lg:mb-4 lg:last:mb-0" />;
};
const NavbarList = (props: N.NavigationMenuListProps) => {
return <N.List {...props} className="lg:flex lg:h-full gap-6" />;
};
/**
* Content that gets rendered when a sub section of the navbar is shown
*/
const NavbarContent = (props: N.NavigationMenuContentProps) => {
return (
<N.Content
{...props}
className={classNames(
'group navbar-content',
'lg:absolute lg:mt-2 pl-2 lg:pl-0 z-20 lg:min-w-[290px]',
'lg:bg-vega-clight-700 lg:dark:bg-vega-cdark-700',
'lg:border border-vega-clight-500 dark:border-vega-cdark-500 lg:rounded'
)}
onPointerEnter={preventHover}
onPointerLeave={preventHover}
/>
);
};
/**
* NavbarLink with OPEN_EXTERNAL icon
*/
const NavbarLinkExternal = ({
children,
to,
onClick,
}: {
children: ReactNode;
to: string;
onClick?: () => void;
}) => {
return (
<N.Link asChild={true}>
<NavLink
to={to}
className={classNames(
'flex lg:inline-flex gap-2 justify-between items-center relative',
'px-6 py-2 lg:p-0 text-lg lg:text-sm',
'hover:text-vega-clight-100 dark:hover:text-vega-cdark-100'
)}
onClick={onClick}
target="_blank"
>
<span>{children}</span>
<VegaIcon name={VegaIconNames.OPEN_EXTERNAL} />
</NavLink>
</N.Link>
);
};
const BurgerIcon = () => (
<svg
width="20"
height="20"
viewBox="0 0 16 16"
className="w-full stroke-current"
>
<line x1={0.5} x2={15.5} y1={3.5} y2={3.5} />
<line x1={0.5} x2={15.5} y1={11.5} y2={11.5} />
</svg>
);
const NavbarListDivider = () => {
return (
<div className="py-2 px-6 lg:px-0" role="separator">
<div className="h-px lg:h-full w-full lg:w-px bg-vega-clight-500 dark:bg-vega-cdark-500" />
</div>
);
};
/**
* Button component to avoid repeating styles for buttons shown on small screens
*/
const NavbarMobileButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button
{...props}
className={classNames(
'w-8 h-8 lg:hidden flex items-center p-1 rounded ',
'hover:bg-vega-clight-500 dark:hover:bg-vega-cdark-500',
'hover:text-vega-clight-50 dark:hover:text-vega-cdark-50'
)}
/>
);
};
const envNameMapping: Record<Networks, string> = {
[Networks.VALIDATOR_TESTNET]: t('VALIDATOR_TESTNET'),
[Networks.CUSTOM]: t('Custom'),
[Networks.DEVNET]: t('Devnet'),
[Networks.STAGNET1]: t('Stagnet'),
[Networks.TESTNET]: t('Fairground testnet'),
[Networks.MAINNET_MIRROR]: t('Mirror'),
[Networks.MAINNET]: t('Mainnet'),
};
// https://github.com/radix-ui/primitives/issues/1630
// eslint-disable-next-line
const preventHover = (e: any) => {
e.preventDefault();
};
@@ -1,10 +1,14 @@
import { OrderbookManager } from '@vegaprotocol/market-depth'; import { OrderbookManager } from '@vegaprotocol/market-depth';
import { useCreateOrderStore } from '@vegaprotocol/orders'; import { useCreateOrderStore } from '@vegaprotocol/orders';
import { ViewType, useSidebar } from '../sidebar'; import { ViewType, useSidebar } from '../sidebar';
import { useStopOrderFormValues } from '@vegaprotocol/deal-ticket';
export const OrderbookContainer = ({ marketId }: { marketId: string }) => { export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
const useOrderStoreRef = useCreateOrderStore(); const useOrderStoreRef = useCreateOrderStore();
const updateOrder = useOrderStoreRef((store) => store.update); const updateOrder = useOrderStoreRef((store) => store.update);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
);
const setView = useSidebar((store) => store.setView); const setView = useSidebar((store) => store.setView);
return ( return (
<OrderbookManager <OrderbookManager
@@ -12,9 +16,11 @@ export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
onClick={({ price, size }) => { onClick={({ price, size }) => {
if (price) { if (price) {
updateOrder(marketId, { price }); updateOrder(marketId, { price });
updateStoredFormValues(marketId, { price });
} }
if (size) { if (size) {
updateOrder(marketId, { size }); updateOrder(marketId, { size });
updateStoredFormValues(marketId, { size });
} }
setView({ type: ViewType.Order }); setView({ type: ViewType.Order });
}} }}
+5 -4
View File
@@ -51,9 +51,10 @@ type SidebarView =
}; };
export const Sidebar = () => { export const Sidebar = () => {
const navClasses = 'flex lg:flex-col items-center gap-2 lg:gap-4 p-1';
return ( return (
<div className="flex flex-col gap-2 h-full py-1" data-testid="sidebar"> <div className="flex lg:flex-col gap-2 h-full p-1" data-testid="sidebar">
<nav className="flex flex-col items-center gap-4 p-1"> <nav className={navClasses}>
{/* sidebar options that always show */} {/* sidebar options that always show */}
<SidebarButton <SidebarButton
view={ViewType.Deposit} view={ViewType.Deposit}
@@ -102,7 +103,7 @@ export const Sidebar = () => {
/> />
</Routes> </Routes>
</nav> </nav>
<nav className="mt-auto flex flex-col items-center gap-4 p-1"> <nav className={classNames(navClasses, 'ml-auto lg:mt-auto lg:ml-0')}>
<SidebarButton <SidebarButton
view={ViewType.Settings} view={ViewType.Settings}
icon={VegaIconNames.COG} icon={VegaIconNames.COG}
@@ -161,7 +162,7 @@ const SidebarButton = ({
const SidebarDivider = () => { const SidebarDivider = () => {
return ( return (
<div <div
className="bg-vega-clight-600 dark:bg-vega-cdark-600 w-4 h-px" className="bg-vega-clight-600 dark:bg-vega-cdark-600 w-px h-4 lg:w-4 lg:h-px"
role="separator" role="separator"
/> />
); );
@@ -0,0 +1 @@
export * from './stop-orders-container';
@@ -0,0 +1,41 @@
import { useDataGridEvents } from '@vegaprotocol/datagrid';
import { t } from '@vegaprotocol/i18n';
import { StopOrdersManager } from '@vegaprotocol/orders';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useMarketClickHandler } from '../../lib/hooks/use-market-click-handler';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { DataGridSlice } from '../../stores/datagrid-store-slice';
import { createDataGridSlice } from '../../stores/datagrid-store-slice';
export const StopOrdersContainer = () => {
const { pubKey, isReadOnly } = useVegaWallet();
const onMarketClick = useMarketClickHandler(true);
const gridStore = useStopOrdersStore((store) => store.gridStore);
const updateGridStore = useStopOrdersStore((store) => store.updateGridStore);
const gridStoreCallbacks = useDataGridEvents(gridStore, (colState) => {
updateGridStore(colState);
});
if (!pubKey) {
return <Splash>{t('Please connect Vega wallet')}</Splash>;
}
return (
<StopOrdersManager
partyId={pubKey}
onMarketClick={onMarketClick}
isReadOnly={isReadOnly}
gridProps={gridStoreCallbacks}
/>
);
};
const useStopOrdersStore = create<DataGridSlice>()(
persist(createDataGridSlice, {
name: 'vega_fills_store',
})
);
@@ -28,7 +28,7 @@ describe('VegaWalletConnectButton', () => {
render(generateJsx({ pubKey: null } as VegaWalletContextShape)); render(generateJsx({ pubKey: null } as VegaWalletContextShape));
const button = screen.getByTestId('connect-vega-wallet'); const button = screen.getByTestId('connect-vega-wallet');
expect(button).toHaveTextContent('Connect Vega wallet'); expect(button).toHaveTextContent('Connect');
fireEvent.click(button); fireEvent.click(button);
expect(mockUpdateDialogOpen).toHaveBeenCalled(); expect(mockUpdateDialogOpen).toHaveBeenCalled();
}); });
@@ -1,148 +1,26 @@
import { useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard'; import CopyToClipboard from 'react-copy-to-clipboard';
import classNames from 'classnames';
import { truncateByChars } from '@vegaprotocol/utils'; import { truncateByChars } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
Drawer,
DropdownMenuSeparator,
VegaIcon, VegaIcon,
VegaIconNames, VegaIconNames,
TradingButton as Button,
Intent,
TradingDropdown,
TradingDropdownTrigger,
TradingDropdownContent,
TradingDropdownRadioGroup,
TradingDropdownSeparator,
TradingDropdownItem,
TradingDropdownRadioItem,
TradingDropdownItemIndicator,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import type { PubKey } from '@vegaprotocol/wallet'; import type { PubKey } from '@vegaprotocol/wallet';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet'; import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import { WalletIcon } from '../icons/wallet';
import { useCopyTimeout } from '@vegaprotocol/react-helpers'; import { useCopyTimeout } from '@vegaprotocol/react-helpers';
import { ViewType, useSidebar } from '../sidebar'; import { ViewType, useSidebar } from '../sidebar';
import classNames from 'classnames';
const MobileWalletButton = ({
isConnected,
activeKey,
}: {
isConnected?: boolean;
activeKey?: PubKey;
}) => {
const { pubKeys, selectPubKey, disconnect, fetchPubKeys } = useVegaWallet();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const setView = useSidebar((store) => store.setView);
const { VEGA_ENV } = useEnvironment();
const isYellow = VEGA_ENV === Networks.TESTNET;
const [drawerOpen, setDrawerOpen] = useState(false);
const mobileDisconnect = useCallback(() => {
setDrawerOpen(false);
disconnect();
}, [disconnect]);
const openDrawer = useCallback(() => {
if (!isConnected) {
openVegaWalletDialog();
setDrawerOpen(false);
} else {
if (fetchPubKeys) {
fetchPubKeys();
}
setDrawerOpen(!drawerOpen);
}
}, [drawerOpen, fetchPubKeys, isConnected, openVegaWalletDialog]);
const iconClass = drawerOpen
? 'hidden'
: isYellow
? 'fill-black'
: 'fill-white';
const [container, setContainer] = useState<HTMLElement | null>(null);
const walletButton = (
<button
className="my-2 transition-all flex flex-col justify-around gap-3 p-2 relative h-[34px]"
onClick={openDrawer}
data-testid="connect-vega-wallet-mobile"
>
<WalletIcon className={iconClass} />
</button>
);
const onSelectItem = useCallback(
(pubkey: string) => {
setDrawerOpen(false);
selectPubKey(pubkey);
},
[selectPubKey]
);
return (
<div className="lg:hidden overflow-hidden flex" ref={setContainer}>
<Drawer
dataTestId="wallets-drawer"
open={drawerOpen}
onChange={setDrawerOpen}
container={container}
trigger={walletButton}
>
<div className="border-l border-default p-2 gap-4 flex flex-col w-full h-full bg-white dark:bg-black dark:text-white justify-between">
<div className="flex h-5 justify-end">
<button
className="transition-all flex flex-col justify-around gap-3 p-2 relative h-[34px]"
onClick={() => setDrawerOpen(false)}
data-testid="connect-vega-wallet-mobile-close"
>
<>
<div
className={classNames(
'w-[26px] h-[2px] bg-black dark:bg-white transition-all translate-y-[7.5px] rotate-45',
{
hidden: !drawerOpen,
}
)}
/>
<div
className={classNames(
'w-[26px] h-[2px] bg-black dark:bg-white transition-all -translate-y-[7.5px] -rotate-45',
{
hidden: !drawerOpen,
}
)}
/>
</>
</button>
</div>
<div className="grow my-4" role="list">
{(pubKeys || []).map((pk) => (
<KeypairListItem
key={pk.publicKey}
pk={pk}
isActive={activeKey?.publicKey === pk.publicKey}
onSelectItem={onSelectItem}
/>
))}
</div>
<div className="flex flex-col gap-2 m-4">
<Button
onClick={() => {
setDrawerOpen(false);
setView({ type: ViewType.Transfer });
}}
fill
>
{t('Transfer')}
</Button>
<Button onClick={mobileDisconnect} fill>
{t('Disconnect')}
</Button>
</div>
</div>
</Drawer>
</div>
);
};
export const VegaWalletConnectButton = () => { export const VegaWalletConnectButton = () => {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -166,96 +44,101 @@ export const VegaWalletConnectButton = () => {
if (isConnected && pubKeys) { if (isConnected && pubKeys) {
return ( return (
<> <TradingDropdown
<div className="hidden lg:block"> open={dropdownOpen}
<DropdownMenu trigger={
open={dropdownOpen} <TradingDropdownTrigger
trigger={ data-testid="manage-vega-wallet"
<DropdownMenuTrigger onClick={() => {
data-testid="manage-vega-wallet" if (fetchPubKeys) {
fetchPubKeys();
}
setDropdownOpen(!dropdownOpen);
}}
>
<Button
size="small"
icon={<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={14} />}
>
{activeKey && <span className="uppercase">{activeKey.name}</span>}
{' | '}
{truncateByChars(pubKey)}
</Button>
</TradingDropdownTrigger>
}
>
<TradingDropdownContent
onInteractOutside={() => setDropdownOpen(false)}
sideOffset={12}
side="bottom"
align="end"
onEscapeKeyDown={() => setDropdownOpen(false)}
>
<div className="min-w-[340px]" data-testid="keypair-list">
<TradingDropdownRadioGroup
value={pubKey}
onValueChange={(value) => {
selectPubKey(value);
}}
>
{pubKeys.map((pk) => (
<KeypairItem
key={pk.publicKey}
pk={pk}
active={pk.publicKey === pubKey}
/>
))}
</TradingDropdownRadioGroup>
<TradingDropdownSeparator />
{!isReadOnly && (
<TradingDropdownItem
data-testid="wallet-transfer"
onClick={() => { onClick={() => {
if (fetchPubKeys) { setView({ type: ViewType.Transfer });
fetchPubKeys(); setDropdownOpen(false);
}
setDropdownOpen(!dropdownOpen);
}} }}
> >
{activeKey && ( {t('Transfer')}
<span className="uppercase">{activeKey.name}</span> </TradingDropdownItem>
)} )}
{': '} <TradingDropdownItem data-testid="disconnect" onClick={disconnect}>
{truncateByChars(pubKey)} {t('Disconnect')}
</DropdownMenuTrigger> </TradingDropdownItem>
} </div>
> </TradingDropdownContent>
<DropdownMenuContent </TradingDropdown>
onInteractOutside={() => setDropdownOpen(false)}
sideOffset={17}
side="bottom"
align="end"
onEscapeKeyDown={() => setDropdownOpen(false)}
>
<div className="min-w-[340px]" data-testid="keypair-list">
<DropdownMenuRadioGroup
value={pubKey}
onValueChange={(value) => {
selectPubKey(value);
}}
>
{pubKeys.map((pk) => (
<KeypairItem key={pk.publicKey} pk={pk} />
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{!isReadOnly && (
<DropdownMenuItem
data-testid="wallet-transfer"
onClick={() => {
setView({ type: ViewType.Transfer });
setDropdownOpen(false);
}}
>
{t('Transfer')}
</DropdownMenuItem>
)}
<DropdownMenuItem data-testid="disconnect" onClick={disconnect}>
{t('Disconnect')}
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MobileWalletButton isConnected activeKey={activeKey} />
</>
); );
} }
return ( return (
<> <Button
<Button data-testid="connect-vega-wallet"
data-testid="connect-vega-wallet" onClick={openVegaWalletDialog}
onClick={openVegaWalletDialog} size="small"
size="sm" intent={Intent.None}
className="hidden lg:block" icon={<VegaIcon name={VegaIconNames.ARROW_RIGHT} size={14} />}
> >
<span className="whitespace-nowrap">{t('Connect Vega wallet')}</span> <span className="whitespace-nowrap uppercase">{t('Connect')}</span>
</Button> </Button>
<MobileWalletButton />
</>
); );
}; };
const KeypairItem = ({ pk }: { pk: PubKey }) => { const KeypairItem = ({ pk, active }: { pk: PubKey; active: boolean }) => {
const [copied, setCopied] = useCopyTimeout(); const [copied, setCopied] = useCopyTimeout();
return ( return (
<DropdownMenuRadioItem value={pk.publicKey}> <TradingDropdownRadioItem value={pk.publicKey}>
<div className="flex-1 mr-2" data-testid={`key-${pk.publicKey}`}> <div
<span className="mr-2"> className={classNames('flex-1 mr-2', {
<span> 'text-default': active,
<span className="uppercase">{pk.name}</span>:{' '} 'text-muted': !active,
{truncateByChars(pk.publicKey)} })}
</span> data-testid={`key-${pk.publicKey}`}
>
<span className={classNames('mr-2 uppercase')}>
{pk.name}
{' | '}
{truncateByChars(pk.publicKey)}
</span> </span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<CopyToClipboard text={pk.publicKey} onCopy={() => setCopied(true)}> <CopyToClipboard text={pk.publicKey} onCopy={() => setCopied(true)}>
@@ -270,46 +153,7 @@ const KeypairItem = ({ pk }: { pk: PubKey }) => {
{copied && <span className="text-xs">{t('Copied')}</span>} {copied && <span className="text-xs">{t('Copied')}</span>}
</span> </span>
</div> </div>
<DropdownMenuItemIndicator /> <TradingDropdownItemIndicator />
</DropdownMenuRadioItem> </TradingDropdownRadioItem>
);
};
const KeypairListItem = ({
pk,
isActive,
onSelectItem,
}: {
pk: PubKey;
isActive: boolean;
onSelectItem: (pk: string) => void;
}) => {
const [copied, setCopied] = useCopyTimeout();
return (
<div
className="flex flex-col w-full ml-4 mr-2 mb-4"
data-testid={`key-${pk.publicKey}-mobile`}
>
<span className="flex gap-2 items-center mr-2">
<button onClick={() => onSelectItem(pk.publicKey)}>
<span className="uppercase">{pk.name}</span>
</button>
{isActive && <VegaIcon name={VegaIconNames.TICK} />}
</span>
<span className="flex gap-2 items-center">
{truncateByChars(pk.publicKey)}{' '}
<CopyToClipboard text={pk.publicKey} onCopy={() => setCopied(true)}>
<button
data-testid="copy-vega-public-key"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">{t('Copy')}</span>
<VegaIcon name={VegaIconNames.COPY} />
</button>
</CopyToClipboard>
{copied && <span className="text-xs">{t('Copied')}</span>}
</span>
</div>
); );
}; };
@@ -0,0 +1 @@
export { VegaWalletMenu } from './vega-wallet-menu';
@@ -0,0 +1,106 @@
import { t } from '@vegaprotocol/i18n';
import { useCopyTimeout } from '@vegaprotocol/react-helpers';
import {
TradingButton as Button,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { truncateByChars } from '@vegaprotocol/utils';
import { useVegaWallet, type PubKey } from '@vegaprotocol/wallet';
import { useCallback, useMemo } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { ViewType, useSidebar } from '../sidebar';
export const VegaWalletMenu = ({
setMenu,
}: {
setMenu: (open: 'nav' | 'wallet' | null) => void;
}) => {
const { pubKey, pubKeys, selectPubKey, disconnect } = useVegaWallet();
const setView = useSidebar((store) => store.setView);
const activeKey = useMemo(() => {
return pubKeys?.find((pk) => pk.publicKey === pubKey);
}, [pubKey, pubKeys]);
const onSelectItem = useCallback(
(pubkey: string) => {
selectPubKey(pubkey);
},
[selectPubKey]
);
return (
<div>
<div className="grow my-4" role="list">
{(pubKeys || []).map((pk) => (
<KeypairListItem
key={pk.publicKey}
pk={pk}
isActive={activeKey?.publicKey === pk.publicKey}
onSelectItem={onSelectItem}
/>
))}
</div>
<div className="flex flex-col gap-2 m-4">
<Button
onClick={() => {
setView({ type: ViewType.Transfer });
setMenu(null);
}}
>
{t('Transfer')}
</Button>
<Button
onClick={async () => {
await disconnect();
setMenu(null);
}}
>
{t('Disconnect')}
</Button>
</div>
</div>
);
};
const KeypairListItem = ({
pk,
isActive,
onSelectItem,
}: {
pk: PubKey;
isActive: boolean;
onSelectItem: (pk: string) => void;
}) => {
const [copied, setCopied] = useCopyTimeout();
return (
<div
className="flex flex-col w-full ml-4 mr-2 mb-4"
data-testid={`key-${pk.publicKey}-mobile`}
>
<span className="flex gap-2 items-center mr-2">
<button type="button" onClick={() => onSelectItem(pk.publicKey)}>
<span className="uppercase">{pk.name}</span>
</button>
{isActive && <VegaIcon name={VegaIconNames.TICK} />}
</span>
<span className="flex gap-2 items-center">
{truncateByChars(pk.publicKey)}{' '}
<CopyToClipboard text={pk.publicKey} onCopy={() => setCopied(true)}>
<button
type="button"
data-testid="copy-vega-public-key"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">{t('Copy')}</span>
<VegaIcon name={VegaIconNames.COPY} />
</button>
</CopyToClipboard>
{copied && <span className="text-xs">{t('Copied')}</span>}
</span>
</div>
);
};
+21 -2
View File
@@ -16,6 +16,7 @@ import {
} from '@vegaprotocol/web3'; } from '@vegaprotocol/web3';
import { import {
envTriggerMapping, envTriggerMapping,
Networks,
NodeSwitcherDialog, NodeSwitcherDialog,
useEnvironment, useEnvironment,
useInitializeEnv, useInitializeEnv,
@@ -25,7 +26,13 @@ import './styles.css';
import { usePageTitleStore } from '../stores'; import { usePageTitleStore } from '../stores';
import DialogsContainer from './dialogs-container'; import DialogsContainer from './dialogs-container';
import ToastsManager from './toasts-manager'; import ToastsManager from './toasts-manager';
import { HashRouter, useLocation, useSearchParams } from 'react-router-dom'; import {
HashRouter,
useLocation,
Route,
Routes,
useSearchParams,
} from 'react-router-dom';
import { Connectors } from '../lib/vega-connectors'; import { Connectors } from '../lib/vega-connectors';
import { AppLoader, DynamicLoader } from '../components/app-loader'; import { AppLoader, DynamicLoader } from '../components/app-loader';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
@@ -39,6 +46,8 @@ import {
ProtocolUpgradeProposalNotification, ProtocolUpgradeProposalNotification,
} from '@vegaprotocol/proposals'; } from '@vegaprotocol/proposals';
import { ViewingBanner } from '../components/viewing-banner'; import { ViewingBanner } from '../components/viewing-banner';
import { NavHeader } from '../components/navbar/nav-header';
import { Routes as AppRoutes } from './client-router';
const DEFAULT_TITLE = t('Welcome to Vega trading!'); const DEFAULT_TITLE = t('Welcome to Vega trading!');
@@ -74,6 +83,7 @@ const InitializeHandlers = () => {
function AppBody({ Component }: AppProps) { function AppBody({ Component }: AppProps) {
const location = useLocation(); const location = useLocation();
const { VEGA_ENV } = useEnvironment();
const gridClasses = classNames( const gridClasses = classNames(
'h-full relative z-0 grid', 'h-full relative z-0 grid',
'grid-rows-[repeat(3,min-content),minmax(0,1fr)]' 'grid-rows-[repeat(3,min-content),minmax(0,1fr)]'
@@ -87,7 +97,16 @@ function AppBody({ Component }: AppProps) {
<Title /> <Title />
<div className={gridClasses}> <div className={gridClasses}>
<AnnouncementBanner /> <AnnouncementBanner />
<Navbar theme="system" /> <Navbar theme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'system'}>
<Routes>
<Route
path={AppRoutes.MARKETS}
// render nothing for markets/all, otherwise markets/:marketId will match with markets/all
element={null}
/>
<Route path={AppRoutes.MARKET} element={<NavHeader />} />
</Routes>
</Navbar>
<div data-testid="banners"> <div data-testid="banners">
<ProtocolUpgradeProposalNotification <ProtocolUpgradeProposalNotification
mode={ProtocolUpgradeCountdownMode.IN_ESTIMATED_TIME_REMAINING} mode={ProtocolUpgradeCountdownMode.IN_ESTIMATED_TIME_REMAINING}
@@ -23,7 +23,7 @@ export const useAccountBalance = (assetId?: string) => {
}, },
[assetId] [assetId]
); );
useDataProvider({ const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider, dataProvider: accountsDataProvider,
variables, variables,
skip: !pubKey || !assetId, skip: !pubKey || !assetId,
@@ -34,7 +34,9 @@ export const useAccountBalance = (assetId?: string) => {
() => ({ () => ({
accountBalance: pubKey ? accountBalance : '', accountBalance: pubKey ? accountBalance : '',
accountDecimals: pubKey ? accountDecimals : null, accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}), }),
[accountBalance, accountDecimals, pubKey] [accountBalance, accountDecimals, pubKey, loading, error]
); );
}; };
@@ -22,7 +22,7 @@ export const useMarketAccountBalance = (marketId: string) => {
}, },
[marketId] [marketId]
); );
useDataProvider({ const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider, dataProvider: accountsDataProvider,
variables: { partyId: pubKey || '' }, variables: { partyId: pubKey || '' },
skip: !pubKey || !marketId, skip: !pubKey || !marketId,
@@ -33,7 +33,9 @@ export const useMarketAccountBalance = (marketId: string) => {
() => ({ () => ({
accountBalance: pubKey ? accountBalance : '', accountBalance: pubKey ? accountBalance : '',
accountDecimals: pubKey ? accountDecimals : null, accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}), }),
[accountBalance, accountDecimals, pubKey] [accountBalance, accountDecimals, pubKey, loading, error]
); );
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"name": "@vegaprotocol/announcements", "name": "@vegaprotocol/announcements",
"version": "0.0.1" "version": "0.0.2"
} }
+1
View File
@@ -1,5 +1,6 @@
export function createLog(name: string) { export function createLog(name: string) {
return (message: string) => { return (message: string) => {
// eslint-disable-next-line no-console
console.log(`[${name}]: ${message}`); console.log(`[${name}]: ${message}`);
}; };
} }
@@ -160,7 +160,7 @@ export function waitForProposal(id: string): Promise<{ id: string }> {
resolve(res.proposal); resolve(res.proposal);
} }
} catch (err) { } catch (err) {
console.log(err); console.error(err);
} }
tick++; tick++;
@@ -15,6 +15,7 @@ export const addImportNodeWallets = () => {
.its('stdout') .its('stdout')
.then((result) => { .then((result) => {
const obj = JSON.parse(result); const obj = JSON.parse(result);
// eslint-disable-next-line no-console
console.log(obj); console.log(obj);
cy.writeFile( cy.writeFile(
'./src/fixtures/wallet/node0RecoveryPhrase', './src/fixtures/wallet/node0RecoveryPhrase',
@@ -30,6 +30,7 @@ export const addValidatorsSelfDelegate = () => {
.its('stdout') .its('stdout')
.then((result) => { .then((result) => {
const obj = JSON.parse(result); const obj = JSON.parse(result);
// eslint-disable-next-line no-console
console.log(obj); console.log(obj);
cy.writeFile( cy.writeFile(
'./src/fixtures/wallet/node0RecoveryPhrase', './src/fixtures/wallet/node0RecoveryPhrase',
@@ -30,8 +30,10 @@ export function addVegaWalletTopUpRewardsPool() {
transferStartEpoch = Number(epochText.replace('Epoch', '')) + 5; transferStartEpoch = Number(epochText.replace('Epoch', '')) + 5;
transferEndEpoch = transferStartEpoch + 100; transferEndEpoch = transferStartEpoch + 100;
/* eslint-disable no-console */
console.log(transferStartEpoch); console.log(transferStartEpoch);
console.log(transferEndEpoch); console.log(transferEndEpoch);
/* eslint-enable */
}); });
}) })
.then(() => { .then(() => {
+4
View File
@@ -9,12 +9,14 @@ export class CustomizedBridge extends Eip1193Bridge {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
async sendAsync(...args: any) { async sendAsync(...args: any) {
// eslint-disable-next-line no-console
console.debug('sendAsync called', ...args); console.debug('sendAsync called', ...args);
return this.send(...args); return this.send(...args);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
override async send(...args: any) { override async send(...args: any) {
// eslint-disable-next-line no-console
console.debug('send called', ...args); console.debug('send called', ...args);
const isCallbackForm = const isCallbackForm =
typeof args[0] === 'object' && typeof args[1] === 'function'; typeof args[0] === 'object' && typeof args[1] === 'function';
@@ -89,6 +91,7 @@ export class CustomizedBridge extends Eip1193Bridge {
// All other transactions the base class works for // All other transactions the base class works for
result = await super.send(method, params); result = await super.send(method, params);
} }
// eslint-disable-next-line no-console
console.debug('result received', method, params, result); console.debug('result received', method, params, result);
if (isCallbackForm) { if (isCallbackForm) {
callback(null, { result }); callback(null, { result });
@@ -96,6 +99,7 @@ export class CustomizedBridge extends Eip1193Bridge {
return result; return result;
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.log(error); console.log(error);
if (isCallbackForm) { if (isCallbackForm) {
callback(error, null); callback(error, null);
@@ -11,6 +11,6 @@ export const CenteredGridCellWrapper = ({
<div <div
className={classNames('flex h-[20px] p-0 justify-items-center', className)} className={classNames('flex h-[20px] p-0 justify-items-center', className)}
> >
<div className="self-center">{children}</div> <div className="w-full self-center">{children}</div>
</div> </div>
); );
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
interface MarketNameCellProps { interface MarketNameCellProps {
value?: string; value?: string | null;
data?: { id?: string; marketId?: string; market?: { id: string } }; data?: { id?: string; marketId?: string; market?: { id: string } };
idPath?: string; idPath?: string;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
@@ -1,5 +1,5 @@
import type { Control } from 'react-hook-form'; import type { Control } from 'react-hook-form';
import type { Market, MarketData } from '@vegaprotocol/markets'; import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { DealTicketMarketAmount } from './deal-ticket-market-amount'; import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
@@ -9,7 +9,8 @@ import type { OrderFormFields } from '../../hooks/use-order-form';
export interface DealTicketAmountProps { export interface DealTicketAmountProps {
control: Control<OrderFormFields>; control: Control<OrderFormFields>;
orderType: Schema.OrderType; orderType: Schema.OrderType;
marketData: MarketData; marketData: StaticMarketData;
marketPrice?: string;
market: Market; market: Market;
sizeError?: string; sizeError?: string;
priceError?: string; priceError?: string;
@@ -21,11 +22,18 @@ export interface DealTicketAmountProps {
export const DealTicketAmount = ({ export const DealTicketAmount = ({
orderType, orderType,
marketData, marketData,
marketPrice,
...props ...props
}: DealTicketAmountProps) => { }: DealTicketAmountProps) => {
switch (orderType) { switch (orderType) {
case Schema.OrderType.TYPE_MARKET: case Schema.OrderType.TYPE_MARKET:
return <DealTicketMarketAmount {...props} marketData={marketData} />; return (
<DealTicketMarketAmount
{...props}
marketData={marketData}
marketPrice={marketPrice}
/>
);
case Schema.OrderType.TYPE_LIMIT: case Schema.OrderType.TYPE_LIMIT:
return <DealTicketLimitAmount {...props} />; return <DealTicketLimitAmount {...props} />;
default: { default: {
@@ -4,9 +4,10 @@ import classNames from 'classnames';
interface Props { interface Props {
side: Side; side: Side;
label?: string;
} }
export const DealTicketButton = ({ side }: Props) => { export const DealTicketButton = ({ side, label }: Props) => {
const buttonClasses = classNames( const buttonClasses = classNames(
'px-10 py-2 uppercase rounded-md text-white w-full', 'px-10 py-2 uppercase rounded-md text-white w-full',
{ {
@@ -17,7 +18,7 @@ export const DealTicketButton = ({ side }: Props) => {
return ( return (
<div className="mb-2"> <div className="mb-2">
<button type="submit" data-testid="place-order" className={buttonClasses}> <button type="submit" data-testid="place-order" className={buttonClasses}>
{t('Place order')} {label || t('Place order')}
</button> </button>
</div> </div>
); );
@@ -1,11 +1,20 @@
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { StopOrder } from './deal-ticket-stop-order';
import {
useStaticMarketData,
useMarket,
useMarketPrice,
} from '@vegaprotocol/markets';
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useThrottledDataProvider } from '@vegaprotocol/data-provider';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import { useMarket, marketDataProvider } from '@vegaprotocol/markets';
import { DealTicket } from './deal-ticket'; import { DealTicket } from './deal-ticket';
import { FLAGS } from '@vegaprotocol/environment';
export interface DealTicketContainerProps { interface DealTicketContainerProps {
marketId: string; marketId: string;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
onClickCollateral?: () => void; onClickCollateral?: () => void;
@@ -14,10 +23,9 @@ export interface DealTicketContainerProps {
export const DealTicketContainer = ({ export const DealTicketContainer = ({
marketId, marketId,
onMarketClick, ...props
onClickCollateral,
onDeposit,
}: DealTicketContainerProps) => { }: DealTicketContainerProps) => {
const type = useDealTicketTypeStore((state) => state.type[marketId]);
const { const {
data: market, data: market,
error: marketError, error: marketError,
@@ -29,15 +37,9 @@ export const DealTicketContainer = ({
error: marketDataError, error: marketDataError,
loading: marketDataLoading, loading: marketDataLoading,
reload, reload,
} = useThrottledDataProvider( } = useStaticMarketData(marketId);
{ const { data: marketPrice } = useMarketPrice(market?.id);
dataProvider: marketDataProvider,
variables: { marketId },
},
1000
);
const create = useVegaTransactionStore((state) => state.create); const create = useVegaTransactionStore((state) => state.create);
return ( return (
<AsyncRenderer <AsyncRenderer
data={market && marketData} data={market && marketData}
@@ -46,14 +48,23 @@ export const DealTicketContainer = ({
reload={reload} reload={reload}
> >
{market && marketData ? ( {market && marketData ? (
<DealTicket FLAGS.STOP_ORDERS &&
market={market} (type === DealTicketType.StopLimit ||
marketData={marketData} type === DealTicketType.StopMarket) ? (
submit={(orderSubmission) => create({ orderSubmission })} <StopOrder
onClickCollateral={onClickCollateral} market={market}
onMarketClick={onMarketClick} marketPrice={marketPrice}
onDeposit={onDeposit} submit={(stopOrdersSubmission) => create({ stopOrdersSubmission })}
/> />
) : (
<DealTicket
{...props}
market={market}
marketPrice={marketPrice}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
/>
)
) : ( ) : (
<Splash> <Splash>
<p>{t('Could not load market')}</p> <p>{t('Could not load market')}</p>
@@ -4,11 +4,11 @@ import classnames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { FeesBreakdown } from '@vegaprotocol/markets'; import { FeesBreakdown } from '@vegaprotocol/markets';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import type { Market } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets';
import type { EstimatePositionQuery } from '@vegaprotocol/positions'; import type { EstimatePositionQuery } from '@vegaprotocol/positions';
import type { EstimateFeesQuery } from '../../hooks/__generated__/EstimateOrder';
import { AccountBreakdownDialog } from '@vegaprotocol/accounts'; import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
import { formatRange, formatValue } from '@vegaprotocol/utils'; import { formatRange, formatValue } from '@vegaprotocol/utils';
@@ -24,6 +24,7 @@ import {
EST_TOTAL_MARGIN_TOOLTIP_TEXT, EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants'; } from '../../constants';
import { useEstimateFees } from '../../hooks';
const emptyValue = '-'; const emptyValue = '-';
@@ -76,26 +77,82 @@ export const DealTicketFeeDetail = ({
}; };
export interface DealTicketFeeDetailsProps { export interface DealTicketFeeDetailsProps {
assetSymbol: string;
order: OrderSubmissionBody['orderSubmission'];
market: Market;
notionalSize: string | null;
}
export const DealTicketFeeDetails = ({
assetSymbol,
order,
market,
notionalSize,
}: DealTicketFeeDetailsProps) => {
const feeEstimate = useEstimateFees(order);
const { settlementAsset: asset } =
market.tradableInstrument.instrument.product;
const { decimals: assetDecimals, quantum } = asset;
const marketDecimals = market.decimalPlaces;
const quoteName = market.tradableInstrument.instrument.product.quoteName;
return (
<>
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
</>
);
};
export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string; generalAccountBalance?: string;
marginAccountBalance?: string; marginAccountBalance?: string;
market: Market; market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string; assetSymbol: string;
notionalSize: string | null;
feeEstimate: EstimateFeesQuery['estimateFees'] | undefined;
positionEstimate: EstimatePositionQuery['estimatePosition']; positionEstimate: EstimatePositionQuery['estimatePosition'];
} }
export const DealTicketFeeDetails = ({ export const DealTicketMarginDetails = ({
marginAccountBalance, marginAccountBalance,
generalAccountBalance, generalAccountBalance,
assetSymbol, assetSymbol,
feeEstimate,
market, market,
onMarketClick, onMarketClick,
notionalSize,
positionEstimate, positionEstimate,
}: DealTicketFeeDetailsProps) => { }: DealTicketMarginDetailsProps) => {
const [breakdownDialog, setBreakdownDialog] = useState(false); const [breakdownDialog, setBreakdownDialog] = useState(false);
const { pubKey: partyId } = useVegaWallet(); const { pubKey: partyId } = useVegaWallet();
const { data: currentMargins } = useDataProvider({ const { data: currentMargins } = useDataProvider({
@@ -110,7 +167,6 @@ export const DealTicketFeeDetails = ({
const { settlementAsset: asset } = const { settlementAsset: asset } =
market.tradableInstrument.instrument.product; market.tradableInstrument.instrument.product;
const { decimals: assetDecimals, quantum } = asset; const { decimals: assetDecimals, quantum } = asset;
const marketDecimals = market.decimalPlaces;
let marginRequiredBestCase: string | undefined = undefined; let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined; let marginRequiredWorstCase: string | undefined = undefined;
if (marginEstimate) { if (marginEstimate) {
@@ -251,41 +307,7 @@ export const DealTicketFeeDetails = ({
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
return ( return (
<div> <>
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
<DealTicketFeeDetail <DealTicketFeeDetail
label={t('Margin required')} label={t('Margin required')}
value={formatRange( value={formatRange(
@@ -351,6 +373,6 @@ export const DealTicketFeeDetails = ({
onClose={onAccountBreakdownDialogClose} onClose={onAccountBreakdownDialogClose}
/> />
)} )}
</div> </>
); );
}; };
@@ -44,12 +44,12 @@ export const DealTicketLimitAmount = ({
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex items-center gap-4"> <div className="flex items-start gap-4">
<div className="flex-1"> <div className="flex-1">
<FormGroup <FormGroup
label={t('Size')} label={t('Size')}
labelFor="input-order-size-limit" labelFor="input-order-size-limit"
className="!mb-1" className="!mb-0"
> >
<Controller <Controller
name="size" name="size"
@@ -78,16 +78,13 @@ export const DealTicketLimitAmount = ({
/> />
</FormGroup> </FormGroup>
</div> </div>
<div className="flex-0 items-center"> <div className="pt-7 leading-10">@</div>
<div className="flex">&nbsp;</div>
<div className="flex">@</div>
</div>
<div className="flex-1"> <div className="flex-1">
<FormGroup <FormGroup
labelFor="input-price-quote" labelFor="input-price-quote"
label={t(`Price (${quoteName})`)} label={t(`Price (${quoteName})`)}
labelAlign="right" labelAlign="right"
className="!mb-1" className="!mb-0"
> >
<Controller <Controller
name="price" name="price"
@@ -5,10 +5,10 @@ import {
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit'; import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils'; import { isMarketInAuction } from '@vegaprotocol/markets';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import classNames from 'classnames';
export type DealTicketMarketAmountProps = Omit< export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps, DealTicketAmountProps,
@@ -19,37 +19,26 @@ export const DealTicketMarketAmount = ({
control, control,
market, market,
marketData, marketData,
marketPrice,
sizeError, sizeError,
update, update,
size, size,
}: DealTicketMarketAmountProps) => { }: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
const price = getMarketPrice(marketData); const price = marketPrice;
const priceFormatted = price const priceFormatted = price
? addDecimalsFormatNumber(price, market.decimalPlaces) ? addDecimalsFormatNumber(price, market.decimalPlaces)
: undefined; : undefined;
const inAuction = isMarketInAuction(marketData.marketTradingMode);
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex items-end gap-4 mb-2"> <div className="flex items-start gap-4">
<div className="flex-1 text-sm">{t('Size')}</div>
<div />
<div className="flex-2 text-sm text-right">
{isMarketInAuction(marketData.marketTradingMode) && (
<Tooltip
description={t(
'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
)}
>
<div>{t(`Indicative price`)}</div>
</Tooltip>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="mb-2 text-sm">{t('Size')}</div>
<Controller <Controller
name="size" name="size"
control={control} control={control}
@@ -76,15 +65,29 @@ export const DealTicketMarketAmount = ({
)} )}
/> />
</div> </div>
<div>@</div> <div className="pt-7 leading-10">@</div>
<div className="flex-1 text-sm text-right" data-testid="last-price"> <div className="flex-1 text-sm text-right">
{priceFormatted && quoteName ? ( {inAuction && (
<> <Tooltip
~{priceFormatted} {quoteName} description={t(
</> 'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
) : ( )}
'-' >
<div className="mb-2">{t(`Indicative price`)}</div>
</Tooltip>
)} )}
<div
data-testid="last-price"
className={classNames('leading-10', { 'pt-7': !inAuction })}
>
{priceFormatted && quoteName ? (
<>
~{priceFormatted} {quoteName}
</>
) : (
'-'
)}
</div>
</div> </div>
</div> </div>
{sizeError && ( {sizeError && (
@@ -0,0 +1,314 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateMarket } from '../../test-helpers';
import { StopOrder } from './deal-ticket-stop-order';
import * as Schema from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing';
import type { StopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import type { FeatureFlags } from '@vegaprotocol/environment';
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
}));
jest.mock('@vegaprotocol/environment', () => {
const actual = jest.requireActual('@vegaprotocol/environment');
return {
...actual,
FLAGS: {
...actual.FLAGS,
STOP_ORDERS: true,
} as FeatureFlags,
};
});
const marketPrice = '200';
const market = generateMarket();
const submit = jest.fn();
function generateJsx(pubKey: string | null = 'pubKey', isReadOnly = false) {
return (
<MockedProvider>
<VegaWalletContext.Provider value={{ pubKey, isReadOnly } as any}>
<StopOrder market={market} marketPrice={marketPrice} submit={submit} />
</VegaWalletContext.Provider>
</MockedProvider>
);
}
const submitButton = 'place-order';
const sizeInput = 'order-size';
const priceInput = 'order-price';
const triggerPriceInput = 'triggerPrice';
const triggerTrailingPercentOffsetInput = 'triggerTrailingPercentOffset';
const orderTypeTrigger = 'order-type-Stop';
const orderTypeLimit = 'order-type-StopLimit';
const orderTypeMarket = 'order-type-StopMarket';
const orderSideBuy = 'order-side-SIDE_BUY';
const orderSideSell = 'order-side-SIDE_SELL';
const triggerDirectionRisesAbove = 'triggerDirection-risesAbove';
// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow';
const expiryStrategySubmit = 'expiryStrategy-submit';
const expiryStrategyCancel = 'expiryStrategy-cancel';
const triggerTypePrice = 'triggerType-price';
const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset';
const expire = 'expire';
const datePicker = 'date-picker-field';
const timeInForce = 'order-tif';
const sizeErrorMessage = 'stop-order-error-message-size';
const priceErrorMessage = 'stop-order-error-message-price';
const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price';
const triggerTrailingPercentOffsetErrorMessage =
'stop-order-error-message-trigger-trailing-percent-offset';
describe('StopOrder', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
it('should display ticket defaults', async () => {
render(generateJsx());
// place order button should always be enabled
expect(screen.getByTestId(submitButton)).toBeEnabled();
// Assert defaults are used
await userEvent.click(screen.getByTestId(orderTypeTrigger));
expect(screen.getByTestId(orderTypeLimit).dataset.state).toEqual('checked');
await userEvent.click(screen.getByTestId(orderTypeLimit));
expect(screen.getByTestId(orderSideBuy).dataset.state).toEqual('checked');
expect(screen.getByTestId(sizeInput)).toHaveDisplayValue('0');
expect(screen.getByTestId(timeInForce)).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
expect(
screen.getByTestId(triggerDirectionRisesAbove).dataset.state
).toEqual('checked');
expect(screen.getByTestId(triggerTypePrice).dataset.state).toEqual(
'checked'
);
expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked');
await userEvent.click(screen.getByTestId(expire));
await waitFor(() => {
expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual(
'checked'
);
});
});
it('should display trigger price as price for market type order', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.type(screen.getByTestId(triggerPriceInput), '10');
expect(screen.getByTestId('price')).toHaveTextContent('10.0');
});
it('should use local storage state for initial values', async () => {
const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_SELL,
size: '0.1',
price: '300.22',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
expire: true,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS,
expiresAt: '2023-07-27T16:43:27.000',
};
useStopOrderFormValues.setState({
formValues: {
[market.id]: values,
},
});
render(generateJsx());
// Assert correct defaults are used from store
await userEvent.click(screen.getByTestId(orderTypeTrigger));
expect(screen.queryByTestId(orderTypeLimit)).toBeChecked();
expect(screen.getByTestId(orderSideSell).dataset.state).toEqual('checked');
expect(screen.getByTestId(sizeInput)).toHaveDisplayValue(
values.size as string
);
expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce);
expect(screen.getByTestId(priceInput)).toHaveDisplayValue(
values.price as string
);
expect(screen.getByTestId(expire).dataset.state).toEqual('checked');
expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual(
'checked'
);
expect(screen.getByTestId(datePicker)).toHaveDisplayValue(
values.expiresAt as string
);
});
it('shows no wallet warning and do not submit if no wallet connected', async () => {
render(generateJsx(null));
await userEvent.type(screen.getByTestId(sizeInput), '1');
await userEvent.type(screen.getByTestId(priceInput), '1');
await userEvent.type(screen.getByTestId(triggerPriceInput), '1');
await userEvent.click(screen.getByTestId(submitButton));
expect(submit).not.toBeCalled();
expect(
screen.getByTestId('deal-ticket-connect-wallet')
).toBeInTheDocument();
});
it('calls submit if form is valid', async () => {
render(generateJsx());
await userEvent.type(screen.getByTestId(sizeInput), '1');
await userEvent.type(screen.getByTestId(priceInput), '1');
await userEvent.type(screen.getByTestId(triggerPriceInput), '1');
await userEvent.click(screen.getByTestId(submitButton));
expect(submit).toBeCalled();
});
it('validates size field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
// default value should be invalid
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(sizeInput), '0.01');
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.queryByTestId(sizeErrorMessage)).toBeNull();
});
it('validates price field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
// price error message should not show if size has error
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// switch to market order type error should disappear
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
// switch back to limit type
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeLimit));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(priceInput));
await userEvent.type(screen.getByTestId(priceInput), '0.01');
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
});
it('validates trigger price field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset));
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull();
// switch back to price trigger type
await userEvent.click(screen.getByTestId(triggerTypePrice));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001');
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(triggerPriceInput));
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01');
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull();
});
it('validates trigger trailing percentage offset field', async () => {
render(generateJsx());
// should not show error with default form values
await userEvent.click(screen.getByTestId(submitButton));
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset));
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'0.09'
);
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'0.1'
);
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// to big value should be invalid
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'99.91'
);
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'99.9'
);
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
});
});
@@ -0,0 +1,590 @@
import type { FormEventHandler } from 'react';
import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
import {
formatNumber,
removeDecimal,
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { useForm, Controller } from 'react-hook-form';
import * as Schema from '@vegaprotocol/types';
import {
Radio,
RadioGroup,
Input,
Checkbox,
FormGroup,
InputError,
Select,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice, type Market } from '@vegaprotocol/markets';
import { t } from '@vegaprotocol/i18n';
import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { timeInForceLabel, useOrder } from '@vegaprotocol/orders';
import {
NoWalletWarning,
REDUCE_ONLY_TOOLTIP,
useNotionalSize,
} from './deal-ticket';
import { TypeToggle } from './type-selector';
import {
useStopOrderFormValues,
type StopOrderFormValues,
} from '../../hooks/use-stop-order-form-values';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-stop-order-submission';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { validateExpiration } from '../../utils';
export interface StopOrderProps {
market: Market;
marketPrice?: string | null;
submit: (order: StopOrdersSubmission) => void;
}
const defaultValues: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
triggerType: 'price',
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
size: '0',
};
const stopSubmit: FormEventHandler = (e) => e.preventDefault();
export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const [, updateOrder] = useOrder(market.id);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
);
const storedFormValues = useStopOrderFormValues(
(state) => state.formValues[market.id]
);
const { handleSubmit, setValue, watch, control, formState } =
useForm<StopOrderFormValues>({
defaultValues: { ...defaultValues, ...storedFormValues },
});
const { errors } = formState;
const lastSubmitTime = useRef(0);
const onSubmit = useCallback(
(data: StopOrderFormValues) => {
const now = new Date().getTime();
if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) {
return;
}
submit(
mapFormValuesToStopOrdersSubmission(
data,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
)
);
lastSubmitTime.current = now;
},
[market.id, market.decimalPlaces, market.positionDecimalPlaces, submit]
);
const side = watch('side');
const expire = watch('expire');
const triggerType = watch('triggerType');
const triggerPrice = watch('triggerPrice');
const timeInForce = watch('timeInForce');
const type = watch('type');
const rawPrice = watch('price');
const rawSize = watch('size');
if (storedFormValues?.size && rawSize !== storedFormValues?.size) {
setValue('size', storedFormValues.size);
}
if (storedFormValues?.price && rawPrice !== storedFormValues?.price) {
setValue('price', storedFormValues.price);
}
const isPriceTrigger = triggerType === 'price';
const size = removeDecimal(rawSize, market.positionDecimalPlaces);
const price =
marketPrice &&
getDerivedPrice(
{
type,
price: rawPrice && removeDecimal(rawPrice, market.decimalPlaces),
},
type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice
? removeDecimal(triggerPrice, market.decimalPlaces)
: marketPrice
);
const notionalSize = useNotionalSize(
price,
size,
market.decimalPlaces,
market.positionDecimalPlaces
);
useEffect(() => {
const subscription = watch((value, { name, type }) => {
updateStoredFormValues(market.id, value);
});
return () => subscription.unsubscribe();
}, [watch, market.id, updateStoredFormValues]);
const { quoteName, settlementAsset: asset } =
market.tradableInstrument.instrument.product;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const priceStep = toDecimal(market?.decimalPlaces);
const trailingPercentOffsetStep = '0.1';
const priceFormatted =
isPriceTrigger && triggerPrice
? formatNumber(triggerPrice, market.decimalPlaces)
: undefined;
return (
<form
onSubmit={isReadOnly || !pubKey ? stopSubmit : handleSubmit(onSubmit)}
noValidate
>
<Controller
name="type"
control={control}
render={({ field }) => {
const { value } = field;
return (
<TypeToggle
value={
value === Schema.OrderType.TYPE_LIMIT
? DealTicketType.StopLimit
: DealTicketType.StopMarket
}
onValueChange={(value) => {
const type = value as DealTicketType;
setDealTicketType(market.id, type);
if (
type === DealTicketType.Limit ||
type === DealTicketType.Market
) {
updateOrder({
type:
type === DealTicketType.Limit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET,
});
return;
}
setValue(
'type',
type === DealTicketType.StopLimit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET
);
}}
/>
);
}}
/>
{errors.type && (
<InputError testId="stop-order-error-message-type">
{errors.type.message}
</InputError>
)}
<Controller
name="side"
control={control}
render={({ field }) => (
<SideSelector value={field.value} onValueChange={field.onChange} />
)}
/>
<FormGroup label={t('Trigger')} compact={true} labelFor="">
<Controller
name="triggerDirection"
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
name="triggerDirection"
onChange={onChange}
value={value}
orientation="horizontal"
className="mb-2"
>
<Radio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id="triggerDirection-risesAbove"
label={'Rises above'}
/>
<Radio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW
}
id="triggerDirection-fallsBelow"
label={'Falls below'}
/>
</RadioGroup>
);
}}
/>
{isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerPrice"
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
control={control}
render={({ field }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<Input
data-testid="triggerPrice"
type="number"
step={priceStep}
appendElement={asset.symbol}
value={value || ''}
{...props}
/>
</div>
);
}}
/>
{errors.triggerPrice && (
<InputError testId="stop-order-error-message-trigger-price">
{errors.triggerPrice.message}
</InputError>
)}
</div>
)}
{!isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerTrailingPercentOffset"
control={control}
rules={{
required: t('You need provide a trailing percent offset'),
min: {
value: trailingPercentOffsetStep,
message: t(
'Trailing percent offset cannot be lower than ' +
trailingPercentOffsetStep
),
},
max: {
value: '99.9',
message: t(
'Trailing percent offset cannot be higher than 99.9'
),
},
validate: validateAmount(
trailingPercentOffsetStep,
'Trailing percentage offset'
),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<Input
type="number"
step={trailingPercentOffsetStep}
appendElement="%"
data-testid="triggerTrailingPercentOffset"
value={value || ''}
{...props}
/>
</div>
);
}}
/>
{errors.triggerTrailingPercentOffset && (
<InputError testId="stop-order-error-message-trigger-trailing-percent-offset">
{errors.triggerTrailingPercentOffset.message}
</InputError>
)}
</div>
)}
<Controller
name="triggerType"
control={control}
rules={{ deps: ['triggerTrailingPercentOffset', 'triggerPrice'] }}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio value="price" id="triggerType-price" label={'Price'} />
<Radio
value="trailingPercentOffset"
id="triggerType-trailingPercentOffset"
label={'Trailing Percent Offset'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
<div className="mb-2">
<div className="flex items-start gap-4">
<FormGroup
labelFor="input-price-quote"
label={t(`Size`)}
className="!mb-0 flex-1"
>
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<Input
id="order-size"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
value={value || ''}
{...props}
/>
);
}}
/>
</FormGroup>
<div className="pt-7 leading-10">@</div>
<div className="flex-1">
{type === Schema.OrderType.TYPE_LIMIT ? (
<FormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
labelAlign="right"
className="!mb-0"
>
<Controller
name="price"
control={control}
rules={{
deps: 'type',
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<Input
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
value={value || ''}
{...props}
/>
);
}}
/>
</FormGroup>
) : (
<div
className="text-sm text-right pt-7 leading-10"
data-testid="price"
>
{priceFormatted && quoteName
? `~${priceFormatted} ${quoteName}`
: '-'}
</div>
)}
</div>
</div>
{errors.size && (
<InputError testId="stop-order-error-message-size">
{errors.size.message}
</InputError>
)}
{!errors.size &&
errors.price &&
type === Schema.OrderType.TYPE_LIMIT && (
<InputError testId="stop-order-error-message-price">
{errors.price.message}
</InputError>
)}
</div>
<div className="mb-2">
<FormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
<Controller
name="timeInForce"
control={control}
render={({ field }) => (
<Select
id="select-time-in-force"
className="w-full"
data-testid="order-tif"
{...field}
>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_IOC)}
</option>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_FOK)}
</option>
</Select>
)}
/>
</FormGroup>
{errors.timeInForce && (
<InputError testId="stop-error-message-tif">
{errors.timeInForce.message}
</InputError>
)}
</div>
<div className="flex gap-2 pb-2 justify-end">
<Checkbox
name="reduce-only"
checked={true}
disabled={true}
label={
<Tooltip description={<span>{t(REDUCE_ONLY_TOOLTIP)}</span>}>
<span className="text-xs">{t('Reduce only')}</span>
</Tooltip>
}
/>
</div>
<div className="mb-2">
<Controller
name="expire"
control={control}
render={({ field }) => {
const { onChange: onCheckedChange, value } = field;
return (
<Checkbox
onCheckedChange={onCheckedChange}
checked={value}
name="expire"
label={'Expire'}
/>
);
}}
/>
</div>
{expire && (
<>
<FormGroup
label={t('Strategy')}
labelFor="expiryStrategy"
compact={true}
>
<Controller
name="expiryStrategy"
control={control}
render={({ field }) => {
return (
<RadioGroup orientation="horizontal" {...field}>
<Radio
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
}
id="expiryStrategy-submit"
label={'Submit'}
/>
<Radio
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
}
id="expiryStrategy-cancel"
label={'Cancel'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
<div className="mb-2">
<Controller
name="expiresAt"
control={control}
rules={{
validate: validateExpiration,
}}
render={({ field }) => {
const { value, onChange: onSelect } = field;
return (
<ExpirySelector
value={value}
onSelect={onSelect}
errorMessage={errors.expiresAt?.message}
/>
);
}}
/>
</div>
</>
)}
<NoWalletWarning pubKey={pubKey} isReadOnly={isReadOnly} asset={asset} />
<DealTicketButton side={side} label={t('Submit Stop Order')} />
<DealTicketFeeDetails
order={{
marketId: market.id,
price: price || undefined,
side,
size,
timeInForce,
type,
}}
notionalSize={notionalSize}
assetSymbol={asset.symbol}
market={market}
/>
</form>
);
};
@@ -22,8 +22,12 @@ import { OrdersDocument } from '@vegaprotocol/orders';
jest.mock('zustand'); jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({ jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />, DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
DealTicketMarginDetails: () => (
<div data-testid="deal-ticket-margin-details" />
),
})); }));
const marketPrice = '200';
const pubKey = 'pubKey'; const pubKey = 'pubKey';
const market = generateMarket(); const market = generateMarket();
const marketData = generateMarketData(); const marketData = generateMarketData();
@@ -36,6 +40,7 @@ function generateJsx(mocks: MockedResponse[] = []) {
<DealTicket <DealTicket
market={market} market={market}
marketData={marketData} marketData={marketData}
marketPrice={marketPrice}
submit={submit} submit={submit}
onDeposit={jest.fn()} onDeposit={jest.fn()}
/> />
@@ -114,30 +119,22 @@ describe('DealTicket', () => {
}); });
it('should display ticket defaults', () => { it('should display ticket defaults', () => {
const { container } = render(generateJsx()); render(generateJsx());
// place order button should always be enabled // place order button should always be enabled
expect(screen.getByTestId('place-order')).toBeEnabled(); expect(screen.getByTestId('place-order')).toBeEnabled();
// Assert defaults are used // Assert defaults are used
expect( expect(screen.getByTestId('order-type-Market')).toBeInTheDocument();
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`) expect(screen.getByTestId('order-type-Limit')).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
).toBeInTheDocument();
const oderTypeLimitToggle = container.querySelector( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
`[data-testid="order-type-${Schema.OrderType.TYPE_LIMIT}"] input[type="radio"]` 'checked'
); );
expect(oderTypeLimitToggle).toBeChecked();
expect( expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input') 'checked'
).toBeChecked(); );
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0'); expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
@@ -147,12 +144,12 @@ describe('DealTicket', () => {
it('should display last price for market type order', () => { it('should display last price for market type order', () => {
render(generateJsx()); render(generateJsx());
act(() => { act(() => {
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); screen.getByTestId('order-type-Market').click();
}); });
// Assert last price is shown // Assert last price is shown
expect(screen.getByTestId('last-price')).toHaveTextContent( expect(screen.getByTestId('last-price')).toHaveTextContent(
// eslint-disable-next-line // eslint-disable-next-line
`~${addDecimal(marketData.markPrice, market.decimalPlaces)} ${ `~${addDecimal(marketPrice, market.decimalPlaces)} ${
market.tradableInstrument.instrument.product.quoteName market.tradableInstrument.instrument.product.quoteName
}` }`
); );
@@ -178,17 +175,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@@ -221,17 +213,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@@ -269,17 +256,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@@ -322,17 +304,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@@ -371,17 +348,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@@ -402,7 +374,7 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
act(() => { act(() => {
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); screen.getByTestId('order-type-Market').click();
}); });
// Only FOK and IOC should be present for type market order // Only FOK and IOC should be present for type market order
@@ -427,7 +399,7 @@ describe('DealTicket', () => {
); );
// Switch to type limit order -> all TIF options should be shown // Switch to type limit order -> all TIF options should be shown
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
expect(screen.getByTestId('order-tif').children).toHaveLength( expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length Object.keys(Schema.OrderTimeInForce).length
); );
@@ -447,7 +419,7 @@ describe('DealTicket', () => {
); );
// Switch back to type market order -> FOK should be preserved from previous selection // Switch back to type market order -> FOK should be preserved from previous selection
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); await userEvent.click(screen.getByTestId('order-type-Market'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
); );
@@ -462,7 +434,7 @@ describe('DealTicket', () => {
); );
// Switch back type limit order -> GTT should be preserved // Switch back type limit order -> GTT should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
); );
@@ -477,7 +449,7 @@ describe('DealTicket', () => {
); );
// Switch to type market order -> IOC should be preserved // Switch to type market order -> IOC should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); await userEvent.click(screen.getByTestId('order-type-Market'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
); );
@@ -487,9 +459,9 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// BUY is selected by default // BUY is selected by default
expect( expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input') 'checked'
).toBeChecked(); );
await userEvent.type(screen.getByTestId('order-size'), '200'); await userEvent.type(screen.getByTestId('order-size'), '200');
@@ -504,7 +476,7 @@ describe('DealTicket', () => {
); );
// Switch to limit order // Switch to limit order
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
// Check all TIF options shown // Check all TIF options shown
expect(screen.getByTestId('order-tif').children).toHaveLength( expect(screen.getByTestId('order-tif').children).toHaveLength(
@@ -4,7 +4,10 @@ import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button'; import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import {
DealTicketFeeDetails,
DealTicketMarginDetails,
} from './deal-ticket-fee-details';
import { ExpirySelector } from './expiry-selector'; import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector'; import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector'; import { TimeInForceSelector } from './time-in-force-selector';
@@ -30,8 +33,7 @@ import {
} from '@vegaprotocol/positions'; } from '@vegaprotocol/positions';
import { toBigNum, removeDecimal } from '@vegaprotocol/utils'; import { toBigNum, removeDecimal } from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders'; import { activeOrdersProvider } from '@vegaprotocol/orders';
import { useEstimateFees } from '../../hooks/use-estimate-fees'; import { getDerivedPrice } from '@vegaprotocol/markets';
import { getDerivedPrice } from '../../utils/get-price';
import type { OrderInfo } from '@vegaprotocol/types'; import type { OrderInfo } from '@vegaprotocol/types';
import { import {
@@ -43,7 +45,11 @@ import {
} from '../../utils'; } from '../../utils';
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants'; import { SummaryValidationType } from '../../constants';
import type { Market, MarketData } from '@vegaprotocol/markets'; import type {
Market,
MarketData,
StaticMarketData,
} from '@vegaprotocol/markets';
import { MarginWarning } from '../deal-ticket-validation/margin-warning'; import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import { import {
useMarketAccountBalance, useMarketAccountBalance,
@@ -53,26 +59,59 @@ import {
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types'; import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form'; import { useOrderForm } from '../../hooks/use-order-form';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
export const REDUCE_ONLY_TOOLTIP =
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
export interface DealTicketProps { export interface DealTicketProps {
market: Market; market: Market;
marketData: MarketData; marketData: StaticMarketData;
marketPrice?: string | null;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
submit: (order: OrderSubmission) => void; submit: (order: OrderSubmission) => void;
onClickCollateral?: () => void; onClickCollateral?: () => void;
onDeposit: (assetId: string) => void; onDeposit: (assetId: string) => void;
} }
export const useNotionalSize = (
price: string | null | undefined,
size: string | undefined,
decimalPlaces: number,
positionDecimalPlaces: number
) =>
useMemo(() => {
if (price && size) {
return removeDecimal(
toBigNum(size, positionDecimalPlaces).multipliedBy(
toBigNum(price, decimalPlaces)
),
decimalPlaces
);
}
return null;
}, [price, size, decimalPlaces, positionDecimalPlaces]);
export const DealTicket = ({ export const DealTicket = ({
market, market,
onMarketClick, onMarketClick,
marketData, marketData,
marketPrice,
submit, submit,
onClickCollateral, onClickCollateral,
onDeposit, onDeposit,
}: DealTicketProps) => { }: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const updateStopOrderFormValues = useStopOrderFormValues(
(state) => state.update
);
// store last used tif for market so that when changing OrderType the previous TIF // store last used tif for market so that when changing OrderType the previous TIF
// selection for that type is used when switching back // selection for that type is used when switching back
@@ -95,11 +134,15 @@ export const DealTicket = ({
const asset = market.tradableInstrument.instrument.product.settlementAsset; const asset = market.tradableInstrument.instrument.product.settlementAsset;
const { accountBalance: marginAccountBalance } = useMarketAccountBalance( const {
market.id accountBalance: marginAccountBalance,
); loading: loadingMarginAccountBalance,
} = useMarketAccountBalance(market.id);
const { accountBalance: generalAccountBalance } = useAccountBalance(asset.id); const {
accountBalance: generalAccountBalance,
loading: loadingGeneralAccountBalance,
} = useAccountBalance(asset.id);
const balance = ( const balance = (
BigInt(marginAccountBalance) + BigInt(generalAccountBalance) BigInt(marginAccountBalance) + BigInt(generalAccountBalance)
@@ -116,30 +159,20 @@ export const DealTicket = ({
); );
const price = useMemo(() => { const price = useMemo(() => {
return normalizedOrder && getDerivedPrice(normalizedOrder, marketData); return (
}, [normalizedOrder, marketData]); normalizedOrder &&
marketPrice &&
getDerivedPrice(normalizedOrder, marketPrice)
);
}, [normalizedOrder, marketPrice]);
const notionalSize = useMemo(() => { const notionalSize = useNotionalSize(
if (price && normalizedOrder?.size) {
return removeDecimal(
toBigNum(
normalizedOrder.size,
market.positionDecimalPlaces
).multipliedBy(toBigNum(price, market.decimalPlaces)),
market.decimalPlaces
);
}
return null;
}, [
price, price,
normalizedOrder?.size, normalizedOrder?.size,
market.decimalPlaces, market.decimalPlaces,
market.positionDecimalPlaces, market.positionDecimalPlaces
]);
const feeEstimate = useEstimateFees(
normalizedOrder && { ...normalizedOrder, price }
); );
const { data: activeOrders } = useDataProvider({ const { data: activeOrders } = useDataProvider({
dataProvider: activeOrdersProvider, dataProvider: activeOrdersProvider,
variables: { partyId: pubKey || '', marketId: market.id }, variables: { partyId: pubKey || '', marketId: market.id },
@@ -197,7 +230,10 @@ export const DealTicket = ({
const hasNoBalance = const hasNoBalance =
!BigInt(generalAccountBalance) && !BigInt(marginAccountBalance); !BigInt(generalAccountBalance) && !BigInt(marginAccountBalance);
if (hasNoBalance) { if (
hasNoBalance &&
!(loadingMarginAccountBalance || loadingGeneralAccountBalance)
) {
setError('summary', { setError('summary', {
message: SummaryValidationType.NoCollateral, message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral, type: SummaryValidationType.NoCollateral,
@@ -221,6 +257,8 @@ export const DealTicket = ({
marketTradingMode, marketTradingMode,
generalAccountBalance, generalAccountBalance,
marginAccountBalance, marginAccountBalance,
loadingMarginAccountBalance,
loadingGeneralAccountBalance,
pubKey, pubKey,
setError, setError,
clearErrors, clearErrors,
@@ -265,11 +303,13 @@ export const DealTicket = ({
); );
// if an order doesn't exist one will be created by the store immediately // if an order doesn't exist one will be created by the store immediately
if (!order || !normalizedOrder) return null; if (!order || !normalizedOrder) {
return null;
}
return ( return (
<form <form
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)} onSubmit={isReadOnly ? noop : handleSubmit(onSubmit)}
noValidate noValidate
data-testid="deal-ticket-form" data-testid="deal-ticket-form"
> >
@@ -284,9 +324,29 @@ export const DealTicket = ({
}} }}
render={() => ( render={() => (
<TypeSelector <TypeSelector
value={order.type} value={
onSelect={(type) => { order.type === OrderType.TYPE_LIMIT
if (type === OrderType.TYPE_NETWORK) return; ? DealTicketType.Limit
: DealTicketType.Market
}
onValueChange={(dealTicketType) => {
setDealTicketType(market.id, dealTicketType);
if (
dealTicketType !== DealTicketType.Limit &&
dealTicketType !== DealTicketType.Market
) {
updateStopOrderFormValues(market.id, {
type:
dealTicketType === DealTicketType.StopLimit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET,
});
return;
}
const type =
dealTicketType === DealTicketType.Limit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET;
update({ update({
type, type,
// when changing type also update the TIF to what was last used of new type // when changing type also update the TIF to what was last used of new type
@@ -333,7 +393,7 @@ export const DealTicket = ({
render={() => ( render={() => (
<SideSelector <SideSelector
value={order.side} value={order.side}
onSelect={(side) => { onValueChange={(side) => {
update({ side }); update({ side });
}} }}
/> />
@@ -344,6 +404,7 @@ export const DealTicket = ({
orderType={order.type} orderType={order.type}
market={market} market={market}
marketData={marketData} marketData={marketData}
marketPrice={marketPrice || undefined}
sizeError={errors.size?.message} sizeError={errors.size?.message}
priceError={errors.price?.message} priceError={errors.price?.message}
update={update} update={update}
@@ -467,9 +528,7 @@ export const DealTicket = ({
? t( ? t(
'"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".' '"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".'
) )
: t( : t(REDUCE_ONLY_TOOLTIP)}
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'
)}
</span> </span>
} }
> >
@@ -541,10 +600,16 @@ export const DealTicket = ({
/> />
<DealTicketButton side={order.side} /> <DealTicketButton side={order.side} />
<DealTicketFeeDetails <DealTicketFeeDetails
onMarketClick={onMarketClick} order={
feeEstimate={feeEstimate} normalizedOrder && { ...normalizedOrder, price: price || undefined }
}
notionalSize={notionalSize} notionalSize={notionalSize}
assetSymbol={assetSymbol} assetSymbol={assetSymbol}
market={market}
/>
<DealTicketMarginDetails
onMarketClick={onMarketClick}
assetSymbol={assetSymbol}
marginAccountBalance={marginAccountBalance} marginAccountBalance={marginAccountBalance}
generalAccountBalance={generalAccountBalance} generalAccountBalance={generalAccountBalance}
positionEstimate={positionEstimate?.estimatePosition} positionEstimate={positionEstimate?.estimatePosition}
@@ -569,6 +634,55 @@ interface SummaryMessageProps {
onClickCollateral?: () => void; onClickCollateral?: () => void;
onDeposit: (assetId: string) => void; onDeposit: (assetId: string) => void;
} }
export const NoWalletWarning = ({
isReadOnly,
pubKey,
asset,
}: Pick<SummaryMessageProps, 'isReadOnly' | 'pubKey' | 'asset'>) => {
const assetSymbol = asset.symbol;
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return (
<div className="mb-2">
<InputError testId="deal-ticket-error-message-summary">
{
'You need to connect your own wallet to start trading on this market'
}
</InputError>
</div>
);
}
if (!pubKey) {
return (
<div className="mb-2">
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'small',
}}
/>
</div>
);
}
return null;
};
const SummaryMessage = memo( const SummaryMessage = memo(
({ ({
errorMessage, errorMessage,
@@ -583,46 +697,16 @@ const SummaryMessage = memo(
}: SummaryMessageProps) => { }: SummaryMessageProps) => {
// Specific error UI for if balance is so we can // Specific error UI for if balance is so we can
// render a deposit dialog // render a deposit dialog
const assetSymbol = asset.symbol; if (isReadOnly || !pubKey) {
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return ( return (
<div className="mb-2"> <NoWalletWarning
<InputError testId="deal-ticket-error-message-summary"> isReadOnly={isReadOnly}
{ asset={asset}
'You need to connect your own wallet to start trading on this market' pubKey={pubKey}
} />
</InputError>
</div>
);
}
if (!pubKey) {
return (
<div className="mb-2">
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'small',
}}
/>
</div>
); );
} }
if (errorMessage === SummaryValidationType.NoCollateral) { if (errorMessage === SummaryValidationType.NoCollateral) {
return ( return (
<div className="mb-2"> <div className="mb-2">
@@ -1,6 +1,7 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/utils'; import { formatForInput } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useRef } from 'react';
interface ExpirySelectorProps { interface ExpirySelectorProps {
value?: string; value?: string;
@@ -13,7 +14,8 @@ export const ExpirySelector = ({
onSelect, onSelect,
errorMessage, errorMessage,
}: ExpirySelectorProps) => { }: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date(); const now = useRef(new Date());
const date = value ? new Date(value) : now.current;
const dateFormatted = formatForInput(date); const dateFormatted = formatForInput(date);
const minDate = formatForInput(date); const minDate = formatForInput(date);
return ( return (
@@ -3,6 +3,8 @@ export * from './deal-ticket-container';
export * from './deal-ticket-limit-amount'; export * from './deal-ticket-limit-amount';
export * from './deal-ticket-market-amount'; export * from './deal-ticket-market-amount';
export * from './deal-ticket'; export * from './deal-ticket';
export * from './deal-ticket-stop-order';
export * from './deal-ticket-container';
export * from './expiry-selector'; export * from './expiry-selector';
export * from './side-selector'; export * from './side-selector';
export * from './time-in-force-selector'; export * from './time-in-force-selector';
@@ -1,46 +1,41 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import * as RadioGroup from '@radix-ui/react-radio-group';
import classNames from 'classnames';
interface SideSelectorProps { interface SideSelectorProps {
value: Schema.Side; value: Schema.Side;
onSelect: (side: Schema.Side) => void; onValueChange: (side: Schema.Side) => void;
} }
export const SideSelector = ({ value, onSelect }: SideSelectorProps) => { const toggles = [
const toggles = [ { label: t('Long'), value: Schema.Side.SIDE_BUY },
{ label: t('Long'), value: Schema.Side.SIDE_BUY }, { label: t('Short'), value: Schema.Side.SIDE_SELL },
{ label: t('Short'), value: Schema.Side.SIDE_SELL }, ];
];
const toggleType = (e: Schema.Side) => {
switch (e) {
case Schema.Side.SIDE_BUY:
return 'buy';
case Schema.Side.SIDE_SELL:
return 'sell';
default:
return 'primary';
}
};
export const SideSelector = (props: SideSelectorProps) => {
return ( return (
<FormGroup <RadioGroup.Root
label={t('Direction')} name="order-side"
labelFor="order-side-toggle" className="mb-2 flex h-10 leading-10"
compact={true} {...props}
> >
<Toggle {toggles.map(({ label, value }) => (
id="order-side-toggle" <RadioGroup.Item value={value} key={value} id={`side-${value}`} asChild>
name="order-side" <button
toggles={toggles} className="flex-1 relative font-alpha text-sm"
checkedValue={value} data-testid={`order-side-${value}`}
type={toggleType(value)} >
onChange={(e) => { {label}
onSelect(e.target.value as Schema.Side); <RadioGroup.Indicator
}} className={classNames('absolute bottom-0 left-0 right-0 h-0.5', {
/> 'bg-market-red': props.value === Schema.Side.SIDE_SELL,
</FormGroup> 'bg-market-green-550': props.value === Schema.Side.SIDE_BUY,
})}
/>
</button>
</RadioGroup.Item>
))}
</RadioGroup.Root>
); );
}; };

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