diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ba9852abb..ac7391e1d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ # Related issues 🔗 -Closes #[Issue number here] +Issue: #[Issue number here] # Description â„šī¸ diff --git a/.github/workflows/console-test-run.yml b/.github/workflows/console-test-run.yml index 82113056a..02867e9bb 100644 --- a/.github/workflows/console-test-run.yml +++ b/.github/workflows/console-test-run.yml @@ -125,7 +125,7 @@ jobs: #---------------------------------------------- - name: Run tests working-directory: ./console-test - run: poetry run pytest -s --numprocesses auto + run: poetry run pytest -v -s --numprocesses auto --dist loadfile --durations=20 - name: Check files run: | ls -al . diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 4d522c9b4..672bb7da0 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -15,6 +15,7 @@ on: - types - utils - i18n + - wallet jobs: publish: diff --git a/apps/explorer-e2e/src/fixtures/mocks/new-asset-proposal.json b/apps/explorer-e2e/src/fixtures/mocks/new-asset-proposal.json new file mode 100644 index 000000000..f9295e651 --- /dev/null +++ b/apps/explorer-e2e/src/fixtures/mocks/new-asset-proposal.json @@ -0,0 +1,26 @@ +{ + "proposalSubmission": { + "rationale": { + "title": "Test new asset proposal", + "description": "E2E test for proposals" + }, + "terms": { + "newAsset": { + "changes": { + "name": "USDT Coin", + "symbol": "USDT", + "decimals": "18", + "quantum": "1", + "erc20": { + "contractAddress": "0xb404c51bbc10dcbe948077f18a4b8e553d160084", + "withdrawThreshold": "10", + "lifetimeLimit": "10" + } + } + }, + "closingTimestamp": 1724339572, + "enactmentTimestamp": 1724339572, + "validationTimestamp": 1692799617 + } + } +} diff --git a/apps/explorer-e2e/src/integration/proposal.cy.js b/apps/explorer-e2e/src/integration/proposal.cy.js index 1c6615d90..d3b512a8e 100644 --- a/apps/explorer-e2e/src/integration/proposal.cy.js +++ b/apps/explorer-e2e/src/integration/proposal.cy.js @@ -1,9 +1,10 @@ +import { getNewAssetTxBody } from '../support/governance.functions'; + context('Proposal page', { tags: '@smoke' }, function () { describe('Verify elements on page', function () { const proposalHeading = 'proposals-heading'; const dateTimeRegex = /(\d{1,2})\/(\d{1,2})\/(\d{4}), (\d{1,2}):(\d{1,2}):(\d{1,2})/gm; - const proposalTitle = 'Add Lorem Ipsum market'; before('Create market proposal', function () { cy.visit('/'); @@ -11,6 +12,8 @@ context('Proposal page', { tags: '@smoke' }, function () { }); it('Able to view proposal', function () { + const proposalTitle = 'Add Lorem Ipsum market'; + cy.navigate_to('governanceProposals'); cy.getByTestId(proposalHeading).should('be.visible'); cy.contains(proposalTitle) @@ -22,6 +25,9 @@ context('Proposal page', { tags: '@smoke' }, function () { cy.get_element_by_col_id('type').should('have.text', 'NewMarket'); cy.get_element_by_col_id('state').should('have.text', 'Enacted'); cy.getByTestId('vote-progress').should('be.visible'); + cy.getByTestId('vote-progress-bar-for') + .invoke('attr', 'style') + .should('eq', 'width: 100%;'); cy.get('[col-id="cDate"]') .invoke('text') .should('match', dateTimeRegex); @@ -35,9 +41,12 @@ context('Proposal page', { tags: '@smoke' }, function () { }); cy.getByTestId('dialog-title').should('have.text', proposalTitle); cy.get('.language-json').should('exist'); + cy.getByTestId('icon-cross').click(); }); it.skip('Proposal page displayed on mobile', function () { + const proposalTitle = 'Add Lorem Ipsum market'; + cy.common_switch_to_mobile_and_click_toggle(); cy.navigate_to('governanceProposals', true); cy.getByTestId(proposalHeading).should('be.visible'); @@ -45,5 +54,40 @@ context('Proposal page', { tags: '@smoke' }, function () { cy.get_element_by_col_id('title').should('have.text', proposalTitle); }); }); + + it('Able to view new asset proposal', function () { + const proposalTitle = 'Test new asset proposal'; + const newAssetProposalBody = getNewAssetTxBody(); + cy.VegaWalletSubmitProposal(newAssetProposalBody); + + cy.visit('/'); + cy.navigate_to('governanceProposals'); + cy.contains(proposalTitle) + .parent() + .parent() + .parent() + .within(() => { + cy.get_element_by_col_id('title').should('have.text', proposalTitle); + cy.get_element_by_col_id('type').should('have.text', 'NewAsset'); + cy.get_element_by_col_id('state').should( + 'have.text', + 'Waiting for Node Vote' + ); + cy.getByTestId('vote-progress').should('be.visible'); + cy.getByTestId('vote-progress-bar-against') + .invoke('attr', 'style') + .should('eq', 'width: 100%;'); + cy.get('[col-id="cDate"]') + .invoke('text') + .should('match', dateTimeRegex); + cy.get('[col-id="eDate"]') + .invoke('text') + .should('match', dateTimeRegex); + cy.getByTestId('external-link') + .should('have.attr', 'href') + .and('contains', 'https://governance.fairground.wtf/proposals/'); + cy.contains('View terms').should('exist').click(); + }); + }); }); }); diff --git a/apps/explorer-e2e/src/support/governance.functions.js b/apps/explorer-e2e/src/support/governance.functions.js index a509fa40d..bf0ec9321 100644 --- a/apps/explorer-e2e/src/support/governance.functions.js +++ b/apps/explorer-e2e/src/support/governance.functions.js @@ -1,8 +1,18 @@ +import { addSeconds, millisecondsToSeconds } from 'date-fns'; + export function createSuccessorMarketProposal(parentMarketId) { cy.VegaWalletSubmitProposal(getSuccessorTxBody(parentMarketId)); } function getSuccessorTxBody(parentMarketId) { + const MIN_CLOSE_SEC = 500; + const MIN_ENACT_SEC = 700; + + const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC); + const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC); + const closingTimestamp = millisecondsToSeconds(closingDate.getTime()); + const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime()); + return { proposalSubmission: { rationale: { @@ -122,8 +132,49 @@ function getSuccessorTxBody(parentMarketId) { }, }, }, - closingTimestamp: 1695666618, - enactmentTimestamp: 1695666618, + closingTimestamp, + enactmentTimestamp, + }, + }, + }; +} + +export function getNewAssetTxBody() { + const MIN_CLOSE_SEC = 500; + const MIN_ENACT_SEC = 700; + const MIN_VALID_SEC = 60; + + const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC); + const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC); + const validationDate = addSeconds(new Date(), MIN_VALID_SEC); + + const closingTimestamp = millisecondsToSeconds(closingDate.getTime()); + const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime()); + const validationTimestamp = millisecondsToSeconds(validationDate.getTime()); + + return { + proposalSubmission: { + rationale: { + title: 'Test new asset proposal', + description: 'E2E test for proposals', + }, + terms: { + newAsset: { + changes: { + name: 'USDT Coin', + symbol: 'USDT', + decimals: '18', + quantum: '1', + erc20: { + contractAddress: '0xb404c51bbc10dcbe948077f18a4b8e553d160084', + withdrawThreshold: '10', + lifetimeLimit: '10', + }, + }, + }, + closingTimestamp, + enactmentTimestamp, + validationTimestamp, }, }, }; diff --git a/apps/explorer/project.json b/apps/explorer/project.json index eece002b4..4f374de6e 100644 --- a/apps/explorer/project.json +++ b/apps/explorer/project.json @@ -77,7 +77,7 @@ "executor": "nx:run-commands", "options": { "commands": [ - "npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/spec-update-v0.72.0-preview.2/specs/v0.72.0-preview.2/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types" + "npx openapi-typescript https://raw.githubusercontent.com/vegaprotocol/documentation/main/specs/v0.72.3/blockexplorer.openapi.json --output apps/explorer/src/types/explorer.d.ts --immutable-types" ] } }, diff --git a/apps/explorer/src/app/components/order-details/StopOrder.graphql b/apps/explorer/src/app/components/order-details/StopOrder.graphql new file mode 100644 index 000000000..17d269ee4 --- /dev/null +++ b/apps/explorer/src/app/components/order-details/StopOrder.graphql @@ -0,0 +1,25 @@ +fragment ExplorerStopOrderFields on StopOrder { + id + status + createdAt + trigger { + ... on StopOrderPrice { + price + } + ... on StopOrderTrailingPercentOffset { + trailingPercentOffset + } + } + createdAt + ocoLinkId + triggerDirection + order { + id + } +} + +query ExplorerStopOrder($stopOrderId: ID!) { + stopOrder(id: $stopOrderId) { + ...ExplorerStopOrderFields + } +} diff --git a/apps/explorer/src/app/components/order-details/__generated__/StopOrder.ts b/apps/explorer/src/app/components/order-details/__generated__/StopOrder.ts new file mode 100644 index 000000000..5a55c3642 --- /dev/null +++ b/apps/explorer/src/app/components/order-details/__generated__/StopOrder.ts @@ -0,0 +1,70 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerStopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, status: Types.StopOrderStatus, createdAt: any, ocoLinkId?: string | null, triggerDirection: Types.StopOrderTriggerDirection, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, order?: { __typename?: 'Order', id: string } | null }; + +export type ExplorerStopOrderQueryVariables = Types.Exact<{ + stopOrderId: Types.Scalars['ID']; +}>; + + +export type ExplorerStopOrderQuery = { __typename?: 'Query', stopOrder?: { __typename?: 'StopOrder', id: string, status: Types.StopOrderStatus, createdAt: any, ocoLinkId?: string | null, triggerDirection: Types.StopOrderTriggerDirection, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, order?: { __typename?: 'Order', id: string } | null } | null }; + +export const ExplorerStopOrderFieldsFragmentDoc = gql` + fragment ExplorerStopOrderFields on StopOrder { + id + status + createdAt + trigger { + ... on StopOrderPrice { + price + } + ... on StopOrderTrailingPercentOffset { + trailingPercentOffset + } + } + createdAt + ocoLinkId + triggerDirection + order { + id + } +} + `; +export const ExplorerStopOrderDocument = gql` + query ExplorerStopOrder($stopOrderId: ID!) { + stopOrder(id: $stopOrderId) { + ...ExplorerStopOrderFields + } +} + ${ExplorerStopOrderFieldsFragmentDoc}`; + +/** + * __useExplorerStopOrderQuery__ + * + * To run a query within a React component, call `useExplorerStopOrderQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerStopOrderQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerStopOrderQuery({ + * variables: { + * stopOrderId: // value for 'stopOrderId' + * }, + * }); + */ +export function useExplorerStopOrderQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerStopOrderDocument, options); + } +export function useExplorerStopOrderLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerStopOrderDocument, options); + } +export type ExplorerStopOrderQueryHookResult = ReturnType; +export type ExplorerStopOrderLazyQueryHookResult = ReturnType; +export type ExplorerStopOrderQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/components/order-summary/order-tx-summary.spec.tsx b/apps/explorer/src/app/components/order-summary/order-tx-summary.spec.tsx index 3d302d910..88e2270dd 100644 --- a/apps/explorer/src/app/components/order-summary/order-tx-summary.spec.tsx +++ b/apps/explorer/src/app/components/order-summary/order-tx-summary.spec.tsx @@ -33,7 +33,7 @@ describe('Order TX Summary component', () => { expect(res.queryByTestId('order-summary')).not.toBeInTheDocument(); }); - it('Renders nothing if the order passed lacks a price', () => { + it('Renders "Market Price" if the order passed lacks a price', () => { const o: Order = { marketId: '123', side: 'SIDE_BUY', @@ -41,7 +41,7 @@ describe('Order TX Summary component', () => { size: '10', }; const res = renderComponent(o); - expect(res.queryByTestId('order-summary')).not.toBeInTheDocument(); + expect(res.queryByText('Market Price')).toBeInTheDocument(); }); it('Renders nothing if the order has an unspecified side', () => { diff --git a/apps/explorer/src/app/components/order-summary/order-tx-summary.tsx b/apps/explorer/src/app/components/order-summary/order-tx-summary.tsx index 77d20ede5..6ece5a4f0 100644 --- a/apps/explorer/src/app/components/order-summary/order-tx-summary.tsx +++ b/apps/explorer/src/app/components/order-summary/order-tx-summary.tsx @@ -3,6 +3,7 @@ import type { components } from '../../../types/explorer'; import PriceInMarket from '../price-in-market/price-in-market'; import { sideText } from '../order-details/lib/order-labels'; import SizeInMarket from '../size-in-market/size-in-market'; +import { t } from '@vegaprotocol/i18n'; export type OrderSummaryProps = { order: components['schemas']['v1OrderSubmission']; @@ -20,7 +21,6 @@ const OrderTxSummary = ({ order }: OrderSummaryProps) => { if ( !order || !order.marketId || - !order.price || !order.side || order.side === 'SIDE_UNSPECIFIED' ) { @@ -36,10 +36,14 @@ const OrderTxSummary = ({ order }: OrderSummaryProps) => { '-' )}  @  - + {order.price ? ( + + ) : ( + t('Market Price') + )} ); }; diff --git a/apps/explorer/src/app/components/txs/details/order/stop-order-setup.tsx b/apps/explorer/src/app/components/txs/details/order/stop-order-setup.tsx new file mode 100644 index 000000000..fadef46ed --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/order/stop-order-setup.tsx @@ -0,0 +1,139 @@ +import { t } from '@vegaprotocol/i18n'; +import type { components } from '../../../../../types/explorer'; +import { formatNumberPercentage } from '@vegaprotocol/utils'; +import BigNumber from 'bignumber.js'; +import PriceInMarket from '../../../price-in-market/price-in-market'; +import StopOrderTriggerSummary from './stop-order-trigger'; +import { Tooltip } from '@vegaprotocol/ui-toolkit'; +import fromUnixTime from 'date-fns/fromUnixTime'; + +const wrapperClasses = + 'flex-1 max-w-xs items-center border border-vega-light-200 dark:border-vega-dark-150 rounded-md pv-2 ph-5 mb-5'; + +export type StopOrderType = 'RisesAbove' | 'FallsBelow' | 'OCO'; +type V1OrderSetup = components['schemas']['v1StopOrderSetup']; + +interface StopOrderSetupProps extends V1OrderSetup { + type: StopOrderType; + deterministicId: string; +} + +export function getExpiryTypeLabel( + expiryStrategy: V1OrderSetup['expiryStrategy'] +): string { + switch (expiryStrategy) { + case 'EXPIRY_STRATEGY_CANCELS': + return t('Cancels'); + case 'EXPIRY_STRATEGY_SUBMIT': + return t('Submit'); + } + + return expiryStrategy || t('Unknown'); +} + +export interface ExpiryTriggerProps { + trailingPercentOffset?: string; + price?: string; + marketId?: string; +} + +export function ExpiryTrigger({ + trailingPercentOffset, + price, + marketId, +}: ExpiryTriggerProps) { + if (price && marketId) { + return ; + } + if (trailingPercentOffset) { + return ( + + {formatNumberPercentage(new BigNumber(trailingPercentOffset))}{' '} + (trailing) + + ); + } + + return null; +} + +export function getMovePrefix( + type: StopOrderType, + trailingPercentOffset?: string +): string { + if (type === 'RisesAbove') { + if (trailingPercentOffset) { + return '+'; + } else { + return '>'; + } + } else { + if (trailingPercentOffset) { + return '-'; + } else { + return '<'; + } + } +} + +export const TypeLabel = { + RisesAbove: t('Rises above ↗'), + FallsBelow: t('Falls below ↘'), + OCO: '', +}; + +/** + */ +export const StopOrderSetup = ({ + type, + price, + orderSubmission, + expiresAt, + expiryStrategy, + trailingPercentOffset, + deterministicId, +}: StopOrderSetupProps) => { + let d = 'Unknown'; + try { + d = expiresAt + ? fromUnixTime(parseInt(expiresAt) / 1000000000).toLocaleString() + : t('Unknown'); + } catch (e) { + d = t('Unknown'); + } + + return ( +
+
+
+
+ {TypeLabel[type]} +

+ {getMovePrefix(type, trailingPercentOffset)} + +

+
+ + {expiresAt && expiryStrategy ? ( +
+ {t('Expiry Type')} +

+ {d}}> + {getExpiryTypeLabel(expiryStrategy)} + +

+
+ ) : null} +
+ +
+
+ ); +}; diff --git a/apps/explorer/src/app/components/txs/details/order/stop-order-trigger.tsx b/apps/explorer/src/app/components/txs/details/order/stop-order-trigger.tsx new file mode 100644 index 000000000..e55d9a519 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/order/stop-order-trigger.tsx @@ -0,0 +1,108 @@ +import { StopOrderStatus } from '@vegaprotocol/types'; +import { useExplorerStopOrderQuery } from '../../../order-details/__generated__/StopOrder'; +import type { ExplorerStopOrderQuery } from '../../../order-details/__generated__/StopOrder'; +import { t } from '@vegaprotocol/i18n'; +import { Icon } from '@vegaprotocol/ui-toolkit'; +import type { IconName } from '@vegaprotocol/ui-toolkit'; +import OrderTxSummary from '../../../order-summary/order-tx-summary'; +import type { components } from '../../../../../types/explorer'; +import type { ApolloError } from '@apollo/client'; + +export const StatusLabel: Record = { + [StopOrderStatus.STATUS_CANCELLED]: t('Cancelled'), + [StopOrderStatus.STATUS_EXPIRED]: t('Expired'), + [StopOrderStatus.STATUS_PENDING]: t('Pending'), + [StopOrderStatus.STATUS_REJECTED]: t('Rejected'), + [StopOrderStatus.STATUS_STOPPED]: t('Stopped'), + [StopOrderStatus.STATUS_TRIGGERED]: t('Triggered'), + [StopOrderStatus.STATUS_UNSPECIFIED]: t('Status unknown'), +}; + +export const StatusIcon: Record = { + [StopOrderStatus.STATUS_CANCELLED]: 'disable', + [StopOrderStatus.STATUS_EXPIRED]: 'outdated', + [StopOrderStatus.STATUS_PENDING]: 'circle', + [StopOrderStatus.STATUS_REJECTED]: 'cross', + [StopOrderStatus.STATUS_STOPPED]: 'stop', + [StopOrderStatus.STATUS_TRIGGERED]: 'tick', + [StopOrderStatus.STATUS_UNSPECIFIED]: 'help', +}; + +export const StatusMidColor: Record = { + [StopOrderStatus.STATUS_CANCELLED]: 'bg-red-100 text-red-900', + [StopOrderStatus.STATUS_EXPIRED]: 'bg-red-100 text-red-900', + [StopOrderStatus.STATUS_PENDING]: 'bg-yellow-100 text-yellow-900', + [StopOrderStatus.STATUS_REJECTED]: 'bg-red-100 text-red-900', + [StopOrderStatus.STATUS_STOPPED]: 'bg-red-100 text-red-900', + [StopOrderStatus.STATUS_TRIGGERED]: 'bg-green-100 text-green-900', + [StopOrderStatus.STATUS_UNSPECIFIED]: 'bg-yellow-100 text-yellow-900', +}; + +export const StatusBottomColor: Record = { + [StopOrderStatus.STATUS_CANCELLED]: 'bg-red-50 text-red-900 line-through', + [StopOrderStatus.STATUS_EXPIRED]: 'bg-red-50 text-red-900 line-through', + [StopOrderStatus.STATUS_PENDING]: 'bg-yellow-50 text-yellow-900', + [StopOrderStatus.STATUS_REJECTED]: 'bg-red-50 text-red-900 line-through', + [StopOrderStatus.STATUS_STOPPED]: 'bg-red-50 text-red-900 line-through', + [StopOrderStatus.STATUS_TRIGGERED]: 'bg-green-50 text-green-900', + [StopOrderStatus.STATUS_UNSPECIFIED]: + 'bg-yellow-50 text-yellow-900 line-through', +}; + +export function getStopOrderTriggerStatus( + data?: ExplorerStopOrderQuery, + error?: ApolloError +) { + if (data && data.stopOrder) { + return data.stopOrder.status; + } + + return StopOrderStatus.STATUS_UNSPECIFIED; +} + +export interface StopOrderTriggerSummaryProps { + id: string; + orderSubmission?: components['schemas']['v1OrderSubmission']; +} + +/** + */ +const StopOrderTriggerSummary = ({ + id, + orderSubmission, +}: StopOrderTriggerSummaryProps) => { + const { data, error } = useExplorerStopOrderQuery({ + variables: { stopOrderId: id }, + }); + + const status = getStopOrderTriggerStatus(data, error); + + return ( + <> +
+

+ + {StatusLabel[status]} +

+
+ +
+ {orderSubmission && ( +

+ +

+ )} +
+ + ); +}; + +export default StopOrderTriggerSummary; diff --git a/apps/explorer/src/app/components/txs/details/order/tx-order-iceberg.tsx b/apps/explorer/src/app/components/txs/details/order/tx-order-iceberg.tsx index d9e3bea69..65738a4ae 100644 --- a/apps/explorer/src/app/components/txs/details/order/tx-order-iceberg.tsx +++ b/apps/explorer/src/app/components/txs/details/order/tx-order-iceberg.tsx @@ -1,10 +1,12 @@ import { t } from '@vegaprotocol/i18n'; import type { components } from '../../../../../types/explorer'; import { Tooltip } from '@vegaprotocol/ui-toolkit'; +import SizeInMarket from '../../../size-in-market/size-in-market'; export interface TxDetailsOrderIcebergDetailsProps { iceberg: components['schemas']['v1IcebergOpts']; size: components['schemas']['v1OrderSubmission']['size']; + marketId?: string; } /** @@ -28,6 +30,7 @@ export interface TxDetailsOrderIcebergDetailsProps { export const TxOrderIcebergDetails = ({ iceberg, size, + marketId, }: TxDetailsOrderIcebergDetailsProps) => { return (
- {iceberg.minimumVisibleSize || '-'} + {marketId ? ( + + ) : ( + iceberg.minimumVisibleSize + )} - {size} + + {marketId ? : size} + - {iceberg.peakSize || '-'} + {marketId ? ( + + ) : ( + iceberg.peakSize + )}
diff --git a/apps/explorer/src/app/components/txs/details/shared/tx-details-shared.tsx b/apps/explorer/src/app/components/txs/details/shared/tx-details-shared.tsx index 656266b9b..b9b202adf 100644 --- a/apps/explorer/src/app/components/txs/details/shared/tx-details-shared.tsx +++ b/apps/explorer/src/app/components/txs/details/shared/tx-details-shared.tsx @@ -26,6 +26,11 @@ export const sharedHeaderProps = { className: 'align-top', }; +const Labels: Record = { + 'Stop Orders Submission': 'Stop Order', + 'Stop Orders Cancellation': 'Cancel Stop Order', +}; + /** * These rows are shown for every transaction type, providing a consistent set of rows for the top * of a transaction details row. The order is relatively arbitrary but felt right - it might need to @@ -44,12 +49,14 @@ export const TxDetailsShared = ({ const time: string = blockData?.result.block.header.time || ''; const height: string = blockData?.result.block.header.height || txData.block; + const type = Labels[txData.type] || txData.type; + return ( <> {hideTypeRow === false ? ( {t('Type')} - {txData.type} + {type} ) : null} diff --git a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx index 7fa30ec68..3992696da 100644 --- a/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx +++ b/apps/explorer/src/app/components/txs/details/transfer/blocks/transfer-participants.tsx @@ -31,6 +31,8 @@ const AccountType: Record = { ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES: 'LP Received Fees', ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS: 'Market Proposers', ACCOUNT_TYPE_HOLDING: 'Holding', + ACCOUNT_TYPE_LIQUIDITY_FEES_BONUS_DISTRIBUTION: 'Bonus Distribution', + ACCOUNT_TYPE_LP_LIQUIDITY_FEES: 'LP Liquidity Fees', }; interface TransferParticipantsProps { diff --git a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx index 509deb263..651eaf4ae 100644 --- a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx @@ -27,6 +27,7 @@ import { TxDetailsNodeAnnounce } from './tx-node-announce'; import { TxDetailsStateVariable } from './tx-state-variable-proposal'; import { TxProposal } from './tx-proposal'; import { TxDetailsTransfer } from './tx-transfer'; +import { TxDetailsStopOrderSubmission } from './tx-stop-order-submission'; interface TxDetailsWrapperProps { txData: BlockExplorerTransactionResult | undefined; @@ -116,6 +117,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) { return TxDetailsUndelegate; case 'State Variable Proposal': return TxDetailsStateVariable; + case 'Stop Orders Submission': + return TxDetailsStopOrderSubmission; case 'Transfer Funds': return TxDetailsTransfer; default: diff --git a/apps/explorer/src/app/components/txs/details/tx-issue-signatures.tsx b/apps/explorer/src/app/components/txs/details/tx-issue-signatures.tsx index 4cc4967ea..b39b8800f 100644 --- a/apps/explorer/src/app/components/txs/details/tx-issue-signatures.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-issue-signatures.tsx @@ -41,7 +41,7 @@ export const TxDetailsIssueSignatures = ({ return <>{t('Awaiting Block Explorer transaction details')}; } - const cmd: Command = txData.command; + const cmd: Command = txData.command.issueSignatures; const k = cmd.kind ? kind[cmd.kind] : null; return ( diff --git a/apps/explorer/src/app/components/txs/details/tx-order.tsx b/apps/explorer/src/app/components/txs/details/tx-order.tsx index 9009a55a4..6d48abbd0 100644 --- a/apps/explorer/src/app/components/txs/details/tx-order.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-order.tsx @@ -81,7 +81,11 @@ export const TxDetailsOrder = ({ {t('Iceberg details')} - + ) : null} diff --git a/apps/explorer/src/app/components/txs/details/tx-stop-order-submission.tsx b/apps/explorer/src/app/components/txs/details/tx-stop-order-submission.tsx new file mode 100644 index 000000000..0aa4e65f2 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/tx-stop-order-submission.tsx @@ -0,0 +1,148 @@ +import { t } from '@vegaprotocol/i18n'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; +import { MarketLink } from '../../links/'; +import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; +import { TxDetailsShared } from './shared/tx-details-shared'; +import { TableCell, TableRow, TableWithTbody } from '../../table'; +import { + getStopOrderIds, + stopOrdersSignatureToDeterministicId, +} from '../lib/deterministic-ids'; +import type { components } from '../../../../types/explorer'; +import { StopOrderSetup } from './order/stop-order-setup'; + +type StopOrderSetup = components['schemas']['v1StopOrderSetup']; + +interface TxDetailsOrderProps { + txData: BlockExplorerTransactionResult | undefined; + pubKey: string | undefined; + blockData: TendermintBlocksResponse | undefined; +} + +export function getStopTypeLabel( + risesAbove: StopOrderSetup | undefined, + fallsBelow: StopOrderSetup | undefined +): string { + if (risesAbove && fallsBelow) { + return t('OCO (One Cancels Other)'); + } else if (fallsBelow) { + return t('Falls Below ↘'); + } else if (risesAbove) { + return t('Rises Above ↗'); + } else { + return t('Stop Order'); + } +} + +export interface StopMarketIdProps { + risesAbove: StopOrderSetup | undefined; + fallsBelow: StopOrderSetup | undefined; + showMarketName?: boolean; +} + +export function StopMarketId({ + risesAbove, + fallsBelow, + showMarketName = false, +}: StopMarketIdProps) { + const raMarketId = risesAbove?.orderSubmission?.marketId; + const fbMarketId = fallsBelow?.orderSubmission?.marketId; + + if (raMarketId && fbMarketId) { + if (raMarketId === fbMarketId) { + return ; + } else { + return ( + <> + , + + + ); + } + } else if (raMarketId) { + return ; + } else if (fbMarketId) { + return ; + } else { + return '-'; + } +} + +/** + */ +export const TxDetailsStopOrderSubmission = ({ + txData, + pubKey, + blockData, +}: TxDetailsOrderProps) => { + if (!txData || !txData.command.stopOrdersSubmission) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const tx: components['schemas']['v1StopOrdersSubmission'] = + txData.command.stopOrdersSubmission; + + const orderIds = stopOrdersSignatureToDeterministicId( + txData?.signature?.value + ); + + const { risesAboveId, fallsBelowId } = getStopOrderIds( + orderIds, + tx.risesAbove, + tx.fallsBelow + ); + + return ( + <> + + + + {t('Market ID')} + + + + + + {t('Market')} + + + + + + {t('Trigger')} + + {getStopTypeLabel(tx.risesAbove, tx.fallsBelow)} + + + +
+ {tx.fallsBelow && fallsBelowId && ( + + )} + + {tx.risesAbove && risesAboveId && ( + + )} +
+ + ); +}; diff --git a/apps/explorer/src/app/components/txs/lib/deterministic-ids.spec.ts b/apps/explorer/src/app/components/txs/lib/deterministic-ids.spec.ts index 6b6cbd1cc..ce62a4538 100644 --- a/apps/explorer/src/app/components/txs/lib/deterministic-ids.spec.ts +++ b/apps/explorer/src/app/components/txs/lib/deterministic-ids.spec.ts @@ -1,4 +1,8 @@ -import { hexToString, txSignatureToDeterministicId } from './deterministic-ids'; +import { + hexToString, + txSignatureToDeterministicId, + stopOrdersSignatureToDeterministicId, +} from './deterministic-ids'; it('txSignatureToDeterministicId Turns a known signature in to a known deterministic ID', () => { const signature = @@ -20,3 +24,24 @@ it('hexToString encodes a known good value as bytes', () => { const res = hexToString(hex); expect(res).toEqual([14, 221]); }); + +describe('stopOrdersSignatureToDeterministicId', () => { + it('should return empty object if no signature is provided', () => { + const result = stopOrdersSignatureToDeterministicId(); + expect(result.length).toEqual(0); + }); + + it('should return valid deterministic ids if a signature is provided', () => { + const signature = 'deadb33f'; + const result = stopOrdersSignatureToDeterministicId(signature); + + expect(result.length).toEqual(2); + + expect(result[0]).toBe( + '4c45b67a8c08cbf1982883a75beaf309bf172461d04bd427623d6cd3d9ab0e91' + ); + expect(result[1]).toBe( + 'afe7509ff90d8f26339a0ab81e4d3e1fb6c4d44e94419aa5e13ae7659d894da1' + ); + }); +}); diff --git a/apps/explorer/src/app/components/txs/lib/deterministic-ids.ts b/apps/explorer/src/app/components/txs/lib/deterministic-ids.ts index e51639599..8dc87e9c6 100644 --- a/apps/explorer/src/app/components/txs/lib/deterministic-ids.ts +++ b/apps/explorer/src/app/components/txs/lib/deterministic-ids.ts @@ -1,4 +1,6 @@ +import type { components } from '../../../../types/explorer'; import { sha3_256 } from 'js-sha3'; +type StopOrderSetup = components['schemas']['v1StopOrderSetup']; /** * Encodes a string as bytes @@ -37,3 +39,58 @@ export function txSignatureToDeterministicId(signature: string): string { return hash.hex(); } + +/** + * Given a stop order signature string, returns the deterministic IDs of both potential + * Stop Orders. A stop order is not an order per se, but a trigger for an order. The order + * created by the stop order will have another ID based on the market event that hit the + * trigger, and as such is not deterministic. + * + * @param signature + * @returns string[] + */ +export function stopOrdersSignatureToDeterministicId( + signature?: string +): string[] { + if (!signature) { + return []; + } + + const firstId = txSignatureToDeterministicId(signature); + return [firstId, txSignatureToDeterministicId(firstId)]; +} + +export type stopSignatures = { + risesAboveId: string | undefined; + fallsBelowId: string | undefined; +}; + +/** + * In 0.72.10 the way stop order IDs are determined is a little tricky. It will be stabilised + * in a future release. + * @param deterministicIds Output of stopORdersSignatureToDeterministicId + * @param risesAbove Stop order setup + * @param fallsBelow Stop order setup + * @returns Object containing the deterministic IDs of the stop orders + */ +export function getStopOrderIds( + deterministicIds: string[], + risesAbove: StopOrderSetup | undefined, + fallsBelow: StopOrderSetup | undefined +) { + if (risesAbove && fallsBelow) { + return { + risesAboveId: deterministicIds[0], + fallsBelowId: deterministicIds[1], + }; + } else if (!fallsBelow && risesAbove) { + return { + risesAboveId: deterministicIds[0], + }; + } else { + return { + fallsBelowId: deterministicIds[0] || undefined, + risesAboveId: deterministicIds[1] || undefined, + }; + } +} diff --git a/apps/explorer/src/app/components/txs/tx-filter.tsx b/apps/explorer/src/app/components/txs/tx-filter.tsx index b0add2253..aeb7e6061 100644 --- a/apps/explorer/src/app/components/txs/tx-filter.tsx +++ b/apps/explorer/src/app/components/txs/tx-filter.tsx @@ -34,6 +34,7 @@ export type FilterOption = | 'Protocol Upgrade' | 'Register new Node' | 'State Variable Proposal' + | 'Stop Orders Submission' | 'Stop Orders Cancellation' | 'Submit Oracle Data' | 'Submit Order' @@ -54,6 +55,7 @@ export const PrimaryFilterOptions: FilterOption[] = [ 'Delegate', 'Liquidity Provision Order', 'Proposal', + 'Stop Orders Submission', 'Stop Orders Cancellation', 'Submit Oracle Data', 'Submit Order', diff --git a/apps/explorer/src/app/components/txs/tx-order-type.tsx b/apps/explorer/src/app/components/txs/tx-order-type.tsx index ad2f53bea..87b9d6473 100644 --- a/apps/explorer/src/app/components/txs/tx-order-type.tsx +++ b/apps/explorer/src/app/components/txs/tx-order-type.tsx @@ -50,6 +50,25 @@ const displayString: StringMap = { 'Stop Orders Cancellation': 'Cancel stop', }; +export function getLabelForStopOrderType( + orderType: string, + command: components['schemas']['v1InputData'] +): string { + if (command.stopOrdersSubmission) { + if ( + command.stopOrdersSubmission.risesAbove && + command.stopOrdersSubmission.fallsBelow + ) { + return 'Stop ⇅'; + } else if (command.stopOrdersSubmission.risesAbove) { + return 'Stop ↗'; + } else if (command.stopOrdersSubmission.fallsBelow) { + return 'Stop ↘'; + } + } + return 'Stop'; +} + export function getLabelForOrderType( orderType: string, command: components['schemas']['v1InputData'] @@ -185,6 +204,9 @@ export const TxOrderType = ({ orderType, command }: TxOrderTypeProps) => { } else if (type === 'Order' && command) { type = getLabelForOrderType(orderType, command); colours = 'text-white dark-text-white bg-vega-blue dark:bg-vega-blue'; + } else if (type === 'Stop' && command) { + type = getLabelForStopOrderType(orderType, command); + colours = 'text-white dark-text-white bg-vega-blue dark:bg-vega-blue'; } if (type === 'Vote on Proposal') { diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx index 23fc54d12..f0fb780f6 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx @@ -55,7 +55,7 @@ export const TxsInfiniteList = ({ } return ( -
+
diff --git a/apps/explorer/src/app/routes/oracles/Oracles.graphql b/apps/explorer/src/app/routes/oracles/Oracles.graphql index 12f6a2266..17adf4e7c 100644 --- a/apps/explorer/src/app/routes/oracles/Oracles.graphql +++ b/apps/explorer/src/app/routes/oracles/Oracles.graphql @@ -1,4 +1,34 @@ +fragment ExplorerOracleDataConnection on OracleSpec { + dataConnection { + edges { + node { + externalData { + data { + signers { + signer { + ... on ETHAddress { + address + } + ... on PubKey { + key + } + } + } + data { + name + value + } + matchedSpecIds + broadcastAt + } + } + } + } + } +} + fragment ExplorerOracleDataSource on OracleSpec { + ...ExplorerOracleDataConnection dataSourceSpec { spec { id @@ -34,6 +64,7 @@ fragment ExplorerOracleDataSource on OracleSpec { key { name type + numberDecimalPlaces } conditions { value @@ -49,38 +80,6 @@ fragment ExplorerOracleDataSource on OracleSpec { } } -fragment ExplorerOracleDataConnection on OracleSpec { - dataConnection { - pageInfo { - hasNextPage - } - edges { - node { - externalData { - data { - signers { - signer { - ... on ETHAddress { - address - } - ... on PubKey { - key - } - } - } - data { - name - value - } - matchedSpecIds - broadcastAt - } - } - } - } - } -} - query ExplorerOracleSpecs { oracleSpecsConnection(pagination: { first: 50 }) { pageInfo { @@ -97,6 +96,5 @@ query ExplorerOracleSpecs { query ExplorerOracleSpecById($id: ID!) { oracleSpec(oracleSpecId: $id) { ...ExplorerOracleDataSource - ...ExplorerOracleDataConnection } } diff --git a/apps/explorer/src/app/routes/oracles/__generated__/Oracles.ts b/apps/explorer/src/app/routes/oracles/__generated__/Oracles.ts index bbdcb3a23..5826182fb 100644 --- a/apps/explorer/src/app/routes/oracles/__generated__/Oracles.ts +++ b/apps/explorer/src/app/routes/oracles/__generated__/Oracles.ts @@ -3,24 +3,55 @@ import * as Types from '@vegaprotocol/types'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; -export type ExplorerOracleDataSourceFragment = { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } } }; +export type ExplorerOracleDataConnectionFragment = { __typename?: 'OracleSpec', dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } }; -export type ExplorerOracleDataConnectionFragment = { __typename?: 'OracleSpec', dataConnection: { __typename?: 'OracleDataConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } }; +export type ExplorerOracleDataSourceFragment = { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } }; export type ExplorerOracleSpecsQueryVariables = Types.Exact<{ [key: string]: never; }>; -export type ExplorerOracleSpecsQuery = { __typename?: 'Query', oracleSpecsConnection?: { __typename?: 'OracleSpecsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleSpecEdge', node: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } } } } | null> | null } | null }; +export type ExplorerOracleSpecsQuery = { __typename?: 'Query', oracleSpecsConnection?: { __typename?: 'OracleSpecsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleSpecEdge', node: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } } | null> | null } | null }; export type ExplorerOracleSpecByIdQueryVariables = Types.Exact<{ id: Types.Scalars['ID']; }>; -export type ExplorerOracleSpecByIdQuery = { __typename?: 'Query', oracleSpec?: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean }, edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } | null }; +export type ExplorerOracleSpecByIdQuery = { __typename?: 'Query', oracleSpec?: { __typename?: 'OracleSpec', dataSourceSpec: { __typename?: 'ExternalDataSourceSpec', spec: { __typename?: 'DataSourceSpec', id: string, createdAt: any, updatedAt?: any | null, status: Types.DataSourceSpecStatus, data: { __typename?: 'DataSourceDefinition', sourceType: { __typename?: 'DataSourceDefinitionExternal', sourceType: { __typename?: 'DataSourceSpecConfiguration', signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, filters?: Array<{ __typename?: 'Filter', key: { __typename?: 'PropertyKey', name?: string | null, type: Types.PropertyKeyType, numberDecimalPlaces?: number | null }, conditions?: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator }> | null }> | null } } | { __typename?: 'DataSourceDefinitionInternal', sourceType: { __typename?: 'DataSourceSpecConfigurationTime', conditions: Array<{ __typename?: 'Condition', value?: string | null, operator: Types.ConditionOperator } | null> } } } } }, dataConnection: { __typename?: 'OracleDataConnection', edges?: Array<{ __typename?: 'OracleDataEdge', node: { __typename?: 'OracleData', externalData: { __typename?: 'ExternalData', data: { __typename?: 'Data', matchedSpecIds?: Array | null, broadcastAt: any, signers?: Array<{ __typename?: 'Signer', signer: { __typename?: 'ETHAddress', address?: string | null } | { __typename?: 'PubKey', key?: string | null } }> | null, data?: Array<{ __typename?: 'Property', name: string, value: string }> | null } } } } | null> | null } } | null }; +export const ExplorerOracleDataConnectionFragmentDoc = gql` + fragment ExplorerOracleDataConnection on OracleSpec { + dataConnection { + edges { + node { + externalData { + data { + signers { + signer { + ... on ETHAddress { + address + } + ... on PubKey { + key + } + } + } + data { + name + value + } + matchedSpecIds + broadcastAt + } + } + } + } + } +} + `; export const ExplorerOracleDataSourceFragmentDoc = gql` fragment ExplorerOracleDataSource on OracleSpec { + ...ExplorerOracleDataConnection dataSourceSpec { spec { id @@ -56,6 +87,7 @@ export const ExplorerOracleDataSourceFragmentDoc = gql` key { name type + numberDecimalPlaces } conditions { value @@ -70,40 +102,7 @@ export const ExplorerOracleDataSourceFragmentDoc = gql` } } } - `; -export const ExplorerOracleDataConnectionFragmentDoc = gql` - fragment ExplorerOracleDataConnection on OracleSpec { - dataConnection { - pageInfo { - hasNextPage - } - edges { - node { - externalData { - data { - signers { - signer { - ... on ETHAddress { - address - } - ... on PubKey { - key - } - } - } - data { - name - value - } - matchedSpecIds - broadcastAt - } - } - } - } - } -} - `; + ${ExplorerOracleDataConnectionFragmentDoc}`; export const ExplorerOracleSpecsDocument = gql` query ExplorerOracleSpecs { oracleSpecsConnection(pagination: {first: 50}) { @@ -149,11 +148,9 @@ export const ExplorerOracleSpecByIdDocument = gql` query ExplorerOracleSpecById($id: ID!) { oracleSpec(oracleSpecId: $id) { ...ExplorerOracleDataSource - ...ExplorerOracleDataConnection } } - ${ExplorerOracleDataSourceFragmentDoc} -${ExplorerOracleDataConnectionFragmentDoc}`; + ${ExplorerOracleDataSourceFragmentDoc}`; /** * __useExplorerOracleSpecByIdQuery__ diff --git a/apps/explorer/src/app/routes/oracles/components/oracle-data.spec.tsx b/apps/explorer/src/app/routes/oracles/components/oracle-data.spec.tsx index 0aef657b2..d8da1d0b5 100644 --- a/apps/explorer/src/app/routes/oracles/components/oracle-data.spec.tsx +++ b/apps/explorer/src/app/routes/oracles/components/oracle-data.spec.tsx @@ -1,23 +1,23 @@ import { render } from '@testing-library/react'; import { OracleData } from './oracle-data'; -import type { ExplorerOracleDataConnectionFragment } from '../__generated__/Oracles'; +import { type ExplorerOracleDataConnectionFragment } from '../__generated__/Oracles'; -function renderComponent(data: ExplorerOracleDataConnectionFragment) { +type DataConnection = ExplorerOracleDataConnectionFragment['dataConnection']; + +function renderComponent( + data: ExplorerOracleDataConnectionFragment['dataConnection'] +) { return ; } describe('Oracle Data view', () => { it('Renders nothing when data is null', () => { - const res = render( - renderComponent(null as unknown as ExplorerOracleDataConnectionFragment) - ); + const res = render(renderComponent(null as unknown as DataConnection)); expect(res.container).toBeEmptyDOMElement(); }); it('Renders nothing when dataConnection is empty', () => { - const res = render( - renderComponent({} as ExplorerOracleDataConnectionFragment) - ); + const res = render(renderComponent({} as DataConnection)); expect(res.container).toBeEmptyDOMElement(); }); it('Renders nothing when dataConnection has no edges', () => { @@ -26,7 +26,7 @@ describe('Oracle Data view', () => { dataConnection: { edges: null, }, - } as ExplorerOracleDataConnectionFragment) + } as DataConnection) ); expect(res.container).toBeEmptyDOMElement(); }); @@ -37,7 +37,7 @@ describe('Oracle Data view', () => { dataConnection: { edges: [], }, - } as unknown as ExplorerOracleDataConnectionFragment) + } as unknown as DataConnection) ); expect(res.container).toBeEmptyDOMElement(); }); @@ -47,20 +47,18 @@ describe('Oracle Data view', () => { it('Renders details component when there is data', () => { const res = render( renderComponent({ - dataConnection: { - edges: [ - { - node: { - externalData: { - data: { - broadcastAt: '2022-01-01', - }, + edges: [ + { + node: { + externalData: { + data: { + broadcastAt: '2022-01-01', }, }, }, - ], - }, - } as ExplorerOracleDataConnectionFragment) + }, + ], + } as DataConnection) ); expect(res.getByText('Broadcast data')).toBeInTheDocument(); }); diff --git a/apps/explorer/src/app/routes/oracles/components/oracle-data.tsx b/apps/explorer/src/app/routes/oracles/components/oracle-data.tsx index feb73368a..0922b01e6 100644 --- a/apps/explorer/src/app/routes/oracles/components/oracle-data.tsx +++ b/apps/explorer/src/app/routes/oracles/components/oracle-data.tsx @@ -4,7 +4,7 @@ import filter from 'recursive-key-filter'; import type { ExplorerOracleDataConnectionFragment } from '../__generated__/Oracles'; interface OracleDataTypeProps { - data: ExplorerOracleDataConnectionFragment; + data: ExplorerOracleDataConnectionFragment['dataConnection']; } /** @@ -14,12 +14,7 @@ interface OracleDataTypeProps { * that Does The Job, rather than because it's good. */ export function OracleData({ data }: OracleDataTypeProps) { - if ( - !data || - !data.dataConnection || - !data.dataConnection.edges?.length || - data.dataConnection.edges.length > 1 - ) { + if (!data || !data.edges?.length || data.edges.length > 1) { return null; } @@ -27,7 +22,7 @@ export function OracleData({ data }: OracleDataTypeProps) {
{t('Broadcast data')}
    - {data.dataConnection.edges.map((d) => { + {data.edges.map((d) => { if (!d) { return null; } diff --git a/apps/explorer/src/app/routes/oracles/components/oracle-markets.tsx b/apps/explorer/src/app/routes/oracles/components/oracle-markets.tsx index a77486b47..179269351 100644 --- a/apps/explorer/src/app/routes/oracles/components/oracle-markets.tsx +++ b/apps/explorer/src/app/routes/oracles/components/oracle-markets.tsx @@ -27,8 +27,8 @@ export function OracleMarkets({ id }: OracleMarketsProps) { const m = markets.find((m) => { const p = m.tradableInstrument.instrument.product; if ( - p.dataSourceSpecForSettlementData.id === id || - p.dataSourceSpecForTradingTermination.id === id + p?.dataSourceSpecForSettlementData?.id === id || + p?.dataSourceSpecForTradingTermination?.id === id ) { return true; } @@ -36,15 +36,9 @@ export function OracleMarkets({ id }: OracleMarketsProps) { }); if (m && m.id) { - const type = - id === - m.tradableInstrument.instrument.product.dataSourceSpecForSettlementData - .id - ? 'Settlement for' - : 'Termination for'; return ( - {type} + {getLabel(id, m)} @@ -61,3 +55,14 @@ export function OracleMarkets({ id }: OracleMarketsProps) { ); } + +export function getLabel( + id: string, + m: ExplorerOracleForMarketsMarketFragment | null +): string { + const settlementId = + m?.tradableInstrument?.instrument?.product?.dataSourceSpecForSettlementData + ?.id || null; + + return id === settlementId ? 'Settlement for' : 'Termination for'; +} diff --git a/apps/explorer/src/app/routes/oracles/components/oracle.tsx b/apps/explorer/src/app/routes/oracles/components/oracle.tsx index 236f176fb..e5cc587ce 100644 --- a/apps/explorer/src/app/routes/oracles/components/oracle.tsx +++ b/apps/explorer/src/app/routes/oracles/components/oracle.tsx @@ -22,7 +22,7 @@ export type SourceType = interface OracleDetailsProps { id: string; dataSource: ExplorerOracleDataSourceFragment; - dataConnection?: ExplorerOracleDataConnectionFragment; + dataConnection: ExplorerOracleDataConnectionFragment['dataConnection']; // Defaults to false. Hides the count of 'broadcasts' this oracle has seen showBroadcasts?: boolean; } @@ -41,8 +41,7 @@ export const OracleDetails = ({ showBroadcasts = false, }: OracleDetailsProps) => { const sourceType = dataSource.dataSourceSpec.spec.data.sourceType; - const reportsCount: number = - dataConnection?.dataConnection.edges?.length || 0; + const reportsCount: number = dataConnection.edges?.length || 0; return (
    diff --git a/apps/explorer/src/app/routes/oracles/home/index.tsx b/apps/explorer/src/app/routes/oracles/home/index.tsx index 77d42264d..0ee9abc0b 100644 --- a/apps/explorer/src/app/routes/oracles/home/index.tsx +++ b/apps/explorer/src/app/routes/oracles/home/index.tsx @@ -34,11 +34,15 @@ const Oracles = () => { if (!id) { return null; } + + const dataConnection = o?.node.dataConnection; + return (
    diff --git a/apps/explorer/src/app/routes/oracles/id/index.tsx b/apps/explorer/src/app/routes/oracles/id/index.tsx index f1a61342c..53682ab85 100644 --- a/apps/explorer/src/app/routes/oracles/id/index.tsx +++ b/apps/explorer/src/app/routes/oracles/id/index.tsx @@ -39,7 +39,7 @@ export const Oracle = () => {
    diff --git a/apps/explorer/src/types/explorer.d.ts b/apps/explorer/src/types/explorer.d.ts index 1ac6e98d1..49e391b37 100644 --- a/apps/explorer/src/types/explorer.d.ts +++ b/apps/explorer/src/types/explorer.d.ts @@ -920,6 +920,8 @@ export interface components { * - ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES: Per asset reward account for fees received by liquidity providers * - ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS: Per asset reward account for market proposers when the market goes above some trading threshold * - ACCOUNT_TYPE_HOLDING: Per asset account for holding in-flight unfilled orders' funds + * - ACCOUNT_TYPE_LP_LIQUIDITY_FEES: Network controlled liquidity provider's account, per market, to hold accrued liquidity fees. + * - ACCOUNT_TYPE_LIQUIDITY_FEES_BONUS_DISTRIBUTION: Network controlled liquidity fees bonus distribution account, per market. * @default ACCOUNT_TYPE_UNSPECIFIED * @enum {string} */ @@ -941,7 +943,9 @@ export interface components { | 'ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES' | 'ACCOUNT_TYPE_REWARD_LP_RECEIVED_FEES' | 'ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS' - | 'ACCOUNT_TYPE_HOLDING'; + | 'ACCOUNT_TYPE_HOLDING' + | 'ACCOUNT_TYPE_LP_LIQUIDITY_FEES' + | 'ACCOUNT_TYPE_LIQUIDITY_FEES_BONUS_DISTRIBUTION'; /** Vega representation of an external asset */ readonly vegaAssetDetails: { /** @description Vega built-in asset. */ diff --git a/apps/governance-e2e/.env b/apps/governance-e2e/.env index 347136e3a..2e6ff789a 100644 --- a/apps/governance-e2e/.env +++ b/apps/governance-e2e/.env @@ -20,6 +20,10 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 NX_VEGA_REST_URL=http://localhost:3008/api/v2/ NX_SUCCESSOR_MARKETS=true +NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + #Test configuration variables CYPRESS_FAIRGROUND=false CYPRESS_VEGA_URL=http://localhost:3008/graphql diff --git a/apps/governance-e2e/src/integration/flow/proposal-details.cy.ts b/apps/governance-e2e/src/integration/flow/proposal-details.cy.ts index 13b7e19bb..53f0918e7 100644 --- a/apps/governance-e2e/src/integration/flow/proposal-details.cy.ts +++ b/apps/governance-e2e/src/integration/flow/proposal-details.cy.ts @@ -11,6 +11,9 @@ import { getDateFormatForSpecifiedDays, getProposalFromTitle, getProposalInformationFromTable, + goToMakeNewProposal, + governanceProposalType, + longProposalDescription, proposalChangeType, submitUniqueRawProposal, validateProposalDetailsDiff, @@ -43,7 +46,6 @@ const proposalDetailsTitle = 'proposal-title'; const proposalDetailsDescription = 'proposal-description'; const openProposals = 'open-proposals'; const viewProposalButton = 'view-proposal-btn'; -const proposalDescriptionToggle = 'proposal-description-toggle'; const voteBreakdownToggle = 'vote-breakdown-toggle'; const proposalTermsToggle = 'proposal-json-toggle'; const marketDataToggle = 'proposal-market-data-toggle'; @@ -71,10 +73,13 @@ describe( // 3001-VOTE-050 3001-VOTE-054 3001-VOTE-055 3002-PROP-019 it('Newly created raw proposal details - shows proposal title and full description', function () { - const proposalDescription = - 'I propose that everyone evaluate the following IPFS document and vote Yes if they agree. bafybeigwwctpv37xdcwacqxvekr6e4kaemqsrv34em6glkbiceo3fcy4si'; + const proposalDetails = longProposalDescription; - createRawProposal(); + goToMakeNewProposal(governanceProposalType.RAW); + submitUniqueRawProposal({ + proposalTitle: 'raw proposal with long description', + proposalDescription: proposalDetails, + }); cy.get('@rawProposal').then((rawProposal) => { cy.getByTestId(openProposals).within(() => { getProposalFromTitle(rawProposal.rationale.title).within(() => { @@ -85,12 +90,17 @@ describe( 'contain.text', rawProposal.rationale.title ); - cy.getByTestId(proposalDescriptionToggle).click(); - cy.getByTestId('proposal-description-toggle'); - cy.getByTestId(proposalDetailsDescription) - .find('p') - .should('have.text', proposalDescription); }); + cy.getByTestId(proposalDetailsDescription).within(() => { + cy.get('p').should('not.have.text', 'Hyperlink text'); + cy.getByTestId('show-more-btn').click(); + cy.get('p') + .invoke('text') + .should('have.have.length', 2194) // Full description is displayed + .and('contain', 'Hyperlink text'); + cy.get('a').should('have.attr', 'href'); + }); + // 3001-VOTE-008 getProposalInformationFromTable('ID') .invoke('text') @@ -361,34 +371,6 @@ describe( stakingPageDisassociateAllTokens(); }); - it('Error message should be displayed if error returned from wallet when voting', function () { - const errorMsg = - 'Application error: party has already submitted the maximum number of transactions of this type per epoch (3)'; - - createRawProposal(); - cy.get('@rawProposal').then((rawProposal) => { - getProposalFromTitle(rawProposal.rationale.title).within(() => - cy.getByTestId(viewProposalButton).click() - ); - cy.intercept('POST', '/api/v2/requests', { - jsonrpc: '2.0', - error: { - code: 2001, - message: 'Application error', - data: 'party has already submitted the maximum number of transactions of this type per epoch (3)', - }, - id: '-PK5EGmErnjLhAmzMeclC', - }); - cy.contains('Vote breakdown').should('be.visible', { timeout: 10000 }); - cy.getByTestId('vote-buttons').contains('for').click(); - cy.getByTestId('dialog-title').should( - 'have.text', - 'Transaction failed' - ); - cy.getByTestId('Error').should('have.text', errorMsg); - }); - }); - it('Able to see successor market details with new and updated values', function () { cy.createMarket(); cy.reload(); @@ -425,7 +407,8 @@ describe( // 3003-PMAN-011 3003-PMAN-012 cy.getByTestId(marketDataToggle).click(); cy.getByTestId('proposal-market-data').within(() => { - cy.contains('Key details').click(); + // Assert that all toggles are removed + cy.getByTestId('accordion-toggle').should('not.exist'); validateProposalDetailsDiff( 'Name', proposalChangeType.UPDATED, @@ -449,7 +432,6 @@ describe( 'Opening auction' ); - cy.contains('Instrument').click(); validateProposalDetailsDiff( 'Market Name', proposalChangeType.UPDATED, @@ -457,7 +439,6 @@ describe( 'Test market 1' ); - cy.contains('Metadata').click(); validateProposalDetailsDiff( 'Sector', proposalChangeType.UPDATED, diff --git a/apps/governance-e2e/src/support/governance.functions.ts b/apps/governance-e2e/src/support/governance.functions.ts index f356e2935..e4654c6eb 100644 --- a/apps/governance-e2e/src/support/governance.functions.ts +++ b/apps/governance-e2e/src/support/governance.functions.ts @@ -274,3 +274,6 @@ export enum proposalChangeType { UPDATED = 'Updated', ADDED = 'Added', } + +export const longProposalDescription = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam bibendum orci augue, vel imperdiet augue ultrices sed. In hac habitasse platea dictumst. Sed eget elit vitae nisl tincidunt faucibus. Donec pellentesque mauris nec viverra blandit. Aenean eros diam, tempor eu luctus nec, rhoncus non massa. Mauris libero diam, mattis et enim ut, lobortis pharetra elit. Phasellus vel metus accumsan, rhoncus tellus finibus, blandit mi. In sollicitudin ex ac tortor ornare interdum. Sed est ipsum, vestibulum eget dolor vel, porta luctus elit. Fusce justo nibh, placerat eget sollicitudin eleifend, rhoncus id lorem. Fusce vitae magna vel urna faucibus accumsan quis id purus.\nPraesent convallis dolor sed ante ultricies tempor. Proin sed risus ut libero euismod semper. Duis quis quam sed lacus viverra blandit vel scelerisque diam. Donec interdum, ipsum eget imperdiet ornare, risus augue faucibus lectus, ullamcorper scelerisque erat sapien in purus. Nunc molestie tincidunt felis dignissim vestibulum. Quisque quis ornare enim, non dignissim lectus. Mauris mollis, massa ut maximus consectetur, sem mi lobortis quam, vel malesuada eros tortor nec ex. Cras ac nunc sed erat malesuada varius a quis nulla. Curabitur cursus nec sem sit amet aliquet. Ut tristique tortor neque, a dignissim lectus dictum vel. Praesent sollicitudin bibendum vulputate.\nAenean bibendum tristique diam laoreet posuere. Curabitur ornare lectus ut diam ultricies, ut sodales eros lacinia. Maecenas mauris turpis, gravida non arcu ac, interdum auctor sapien. Vestibulum sed tortor quam. Interdum et malesuada fames ac ante ipsum primis in faucibus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec finibus pulvinar magna, non laoreet lectus molestie nec. Vestibulum tempus mattis vehicula. Praesent in orci lectus. In commodo sollicitudin lacus, et lobortis eros placerat vitae. Proin mi libero, feugiat id pretium posuere, rhoncus ut augue. Cras massa tortor, rutrum sed ex vitae, posuere pretium augue. Donec pellentesque suscipit dignissim. Vivamus convallis a odio vitae sodales. Nullam non eleifend mauris, sed iaculis lectus. Cras facilisis justo at ante.\n[Hyperlink text](https://dweb.link/ipfs/bafybeigwwctpv37xdcwacqxvekr6e4kaemqsrv34em6glkbiceo3fcy4si)'; diff --git a/apps/governance/.env b/apps/governance/.env index 266d00b1b..a6062b69a 100644 --- a/apps/governance/.env +++ b/apps/governance/.env @@ -31,3 +31,4 @@ LC_ALL="en_US.UTF-8" # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true \ No newline at end of file diff --git a/apps/governance/.env.capsule b/apps/governance/.env.capsule index b8814b856..780e7394f 100644 --- a/apps/governance/.env.capsule +++ b/apps/governance/.env.capsule @@ -19,6 +19,9 @@ NX_DELEGATIONS_PAGINATION=50 NX_TRANCHES_SERVICE_URL=https://tranches-stagnet1-k8s.ops.vega.xyz NX_VEGA_REST_URL=http://localhost:3008/api/v2/ +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + NX_TENDERMINT_URL=http://localhost:26617 NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26617/websocket @@ -26,4 +29,5 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26617/websocket CYPRESS_FAIRGROUND=false # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=false \ No newline at end of file +NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false \ No newline at end of file diff --git a/apps/governance/.env.devnet b/apps/governance/.env.devnet index 51aa96100..7f9908cc0 100644 --- a/apps/governance/.env.devnet +++ b/apps/governance/.env.devnet @@ -14,8 +14,12 @@ NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/annou NX_VEGA_REST_URL=https://api.n00.devnet1.vega.xyz/api/v2/ NX_SENTRY_DSN=https://4b8c8a8ba07742648aa4dfe1b8d17e40@o286262.ingest.sentry.io/5882996 +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + NX_TENDERMINT_URL=https://tm.be.devnet1.vega.xyz/ NX_TENDERMINT_WEBSOCKET_URL=wss://be.devnet1.vega.xyz/websocket # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=true \ No newline at end of file +NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true \ No newline at end of file diff --git a/apps/governance/.env.mainnet b/apps/governance/.env.mainnet index 9ac987b42..982a605bf 100644 --- a/apps/governance/.env.mainnet +++ b/apps/governance/.env.mainnet @@ -14,9 +14,12 @@ NX_DELEGATIONS_PAGINATION=50 NX_TRANCHES_SERVICE_URL=https://tranches-mainnet-k8s.ops.vega.xyz NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/mainnet/announcements.json NX_VEGA_REST_URL=https://api.vega.community/api/v2/ +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet NX_TENDERMINT_URL=https://be.vega.community NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=false \ No newline at end of file +NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/.env.mainnet-mirror b/apps/governance/.env.mainnet-mirror index 22fd23a50..0ef3d4076 100644 --- a/apps/governance/.env.mainnet-mirror +++ b/apps/governance/.env.mainnet-mirror @@ -13,9 +13,12 @@ NX_DELEGATIONS_PAGINATION=50 NX_TRANCHES_SERVICE_URL=https://tranches-mainnet-mirror-k8s.ops.vega.xyz NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/mainnet/announcements.json NX_VEGA_REST_URL=https://api.mainnet-mirror.vega.rocks/api/v2/ +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet NX_TENDERMINT_URL=https://be.mainnet-mirror.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.mainnet-mirror.vega.rocks/websocket # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=false \ No newline at end of file +NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/.env.stagnet1 b/apps/governance/.env.stagnet1 index 53f1e733f..70cb17afe 100644 --- a/apps/governance/.env.stagnet1 +++ b/apps/governance/.env.stagnet1 @@ -10,8 +10,12 @@ NX_TRANCHES_SERVICE_URL=https://tranches-stagnet1-k8s.ops.vega.xyz NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json NX_VEGA_REST_URL=https://api.n00.stagnet1.vega.xyz/api/v2/ +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + NX_TENDERMINT_URL=https://tm.n01.stagnet1.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.stagnet1.vega.xyz/websocket # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=true \ No newline at end of file +NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true diff --git a/apps/governance/.env.testnet b/apps/governance/.env.testnet index 0a817b1d2..2135c6f19 100644 --- a/apps/governance/.env.testnet +++ b/apps/governance/.env.testnet @@ -15,8 +15,12 @@ NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/annou NX_VEGA_REST_URL=https://api.n07.testnet.vega.xyz/api/v2/ NX_SENTRY_DSN=https://4b8c8a8ba07742648aa4dfe1b8d17e40@o286262.ingest.sentry.io/5882996 +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=true \ No newline at end of file +NX_SUCCESSOR_MARKETS=true +NX_METAMASK_SNAPS=true diff --git a/apps/governance/.env.validators-testnet b/apps/governance/.env.validators-testnet index 0064e8152..5ca68a594 100644 --- a/apps/governance/.env.validators-testnet +++ b/apps/governance/.env.validators-testnet @@ -12,8 +12,12 @@ NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/annou NX_VEGA_REST_URL=https://api-validators-testnet.vega.rocks/api/v2/ NX_SENTRY_DSN=https://4b8c8a8ba07742648aa4dfe1b8d17e40@o286262.ingest.sentry.io/5882996 +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + NX_TENDERMINT_URL=https://tm.be.validators-testnet.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.validators-testnet.vega. # Cosmic elevator flags -NX_SUCCESSOR_MARKETS=false \ No newline at end of file +NX_SUCCESSOR_MARKETS=false +NX_METAMASK_SNAPS=false diff --git a/apps/governance/src/app.tsx b/apps/governance/src/app.tsx index fa206fe4c..31d7be1a4 100644 --- a/apps/governance/src/app.tsx +++ b/apps/governance/src/app.tsx @@ -41,6 +41,7 @@ import { AppFailure, NodeSwitcherDialog, useNodeSwitcherStore, + DocsLinks, } from '@vegaprotocol/environment'; import { ENV } from './config'; import type { InMemoryCacheConfig } from '@apollo/client'; @@ -109,8 +110,17 @@ const Web3Container = ({ store.connectors, store.initialize, ]); - const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } = - useEnvironment(); + const { + ETHEREUM_PROVIDER_URL, + ETH_LOCAL_PROVIDER_URL, + ETH_WALLET_MNEMONIC, + VEGA_ENV, + VEGA_URL, + VEGA_EXPLORER_URL, + CHROME_EXTENSION_URL, + MOZILLA_EXTENSION_URL, + VEGA_WALLET_URL, + } = useEnvironment(); useEffect(() => { if (chainId) { return initializeConnectors( @@ -139,10 +149,33 @@ const Web3Container = ({ return ; } + if ( + !VEGA_URL || + !VEGA_WALLET_URL || + !VEGA_EXPLORER_URL || + !DocsLinks || + !CHROME_EXTENSION_URL || + !MOZILLA_EXTENSION_URL + ) { + return null; + } + return ( - + @@ -275,7 +308,7 @@ const AppContainer = () => { -
    +
    {t('Loading')}
    } failure={ diff --git a/apps/governance/src/lib/vega-connectors.ts b/apps/governance/src/lib/vega-connectors.ts index 32b305f55..262c1e680 100644 --- a/apps/governance/src/lib/vega-connectors.ts +++ b/apps/governance/src/lib/vega-connectors.ts @@ -1,17 +1,25 @@ +import { FLAGS } from '@vegaprotocol/environment'; import { JsonRpcConnector, ViewConnector, InjectedConnector, + SnapConnector, + DEFAULT_SNAP_ID, } from '@vegaprotocol/wallet'; const urlParams = new URLSearchParams(window.location.search); -export const injected = new InjectedConnector(); export const jsonRpc = new JsonRpcConnector(); +export const injected = new InjectedConnector(); export const view = new ViewConnector(urlParams.get('address')); +export const snap = FLAGS.METAMASK_SNAPS + ? new SnapConnector(DEFAULT_SNAP_ID) + : undefined; + export const Connectors = { injected, jsonRpc, view, + snap, }; diff --git a/apps/governance/src/routes/proposals/components/proposal-description/proposal-description.tsx b/apps/governance/src/routes/proposals/components/proposal-description/proposal-description.tsx index 85901433d..babffb0b9 100644 --- a/apps/governance/src/routes/proposals/components/proposal-description/proposal-description.tsx +++ b/apps/governance/src/routes/proposals/components/proposal-description/proposal-description.tsx @@ -1,44 +1,27 @@ import ReactMarkdown from 'react-markdown'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { RoundedWrapper } from '@vegaprotocol/ui-toolkit'; -import { SubHeading } from '../../../../components/heading'; -import { CollapsibleToggle } from '../../../../components/collapsible-toggle'; +import { RoundedWrapper, ShowMore } from '@vegaprotocol/ui-toolkit'; export const ProposalDescription = ({ description, }: { description: string; -}) => { - const { t } = useTranslation(); - const [showDescription, setShowDescription] = useState(false); - - return ( -
    - - - - - {showDescription && ( - -
    - - {description} - -
    -
    - )} -
    - ); -}; +}) => ( +
    + +
    + + + {description} + + +
    +
    +
    +); diff --git a/apps/governance/src/routes/proposals/components/proposal-market-data/proposal-market-data.tsx b/apps/governance/src/routes/proposals/components/proposal-market-data/proposal-market-data.tsx index bf491b2b3..453bf41f5 100644 --- a/apps/governance/src/routes/proposals/components/proposal-market-data/proposal-market-data.tsx +++ b/apps/governance/src/routes/proposals/components/proposal-market-data/proposal-market-data.tsx @@ -15,8 +15,6 @@ import { SettlementAssetInfoPanel, } from '@vegaprotocol/markets'; import { - Accordion, - AccordionItem, Button, CopyWithTooltip, Dialog, @@ -43,6 +41,9 @@ export const useMarketDataDialogStore = create( }) ); +const marketDataHeaderStyles = + 'font-alpha calt text-base border-b border-vega-dark-200 mt-2 py-2'; + export const ProposalMarketData = ({ marketData, parentMarketData, @@ -76,6 +77,14 @@ export const ProposalMarketData = ({ parentTerminationData !== undefined && isEqual(terminationData, parentTerminationData); + const showParentPriceMonitoringBounds = + parentMarketData?.priceMonitoringSettings?.parameters?.triggers !== + undefined && + !isEqual( + marketData.priceMonitoringSettings?.parameters?.triggers, + parentMarketData?.priceMonitoringSettings?.parameters?.triggers + ); + const getSigners = (data: DataSourceDefinition) => { if (data.sourceType.__typename === 'DataSourceDefinitionExternal') { const signers = data.sourceType.sourceType.signers || []; @@ -108,164 +117,141 @@ export const ProposalMarketData = ({
    - - - } - /> - - } - /> - {isEqual( - getSigners(settlementData), - getSigners(terminationData) - ) ? ( - +

    {t('Key details')}

    + + +

    {t('Instrument')}

    + + + {isEqual( + getSigners(settlementData), + getSigners(terminationData) + ) ? ( + <> +

    {t('Oracle')}

    + + + + ) : ( + <> +

    + {t('Settlement Oracle')} +

    + - ) : ( - <> - - } - /> - - } - /> - - )} - {/*Note: successor markets will not differ in their settlement*/} - {/*assets, so no need to pass in parent market data for comparison.*/} - } - /> - - } - /> - - } - /> - - } - /> - - } - /> - {( - marketData.priceMonitoringSettings?.parameters?.triggers || [] +

    + {t('Termination Oracle')} +

    + + + )} + + {/*Note: successor markets will not differ in their settlement*/} + {/*assets, so no need to pass in parent market data for comparison.*/} + +

    {t('Settlement assets')}

    + + +

    {t('Metadata')}

    + + +

    {t('Risk model')}

    + + +

    {t('Risk parameters')}

    + + +

    {t('Risk factors')}

    + + + {showParentPriceMonitoringBounds && + ( + parentMarketData?.priceMonitoringSettings?.parameters + ?.triggers || [] ).map((_, triggerIndex) => ( - +

    + {t(`Parent price monitoring bounds ${triggerIndex + 1}`)} +

    + +
    - } - /> +
    + ))} - - } - /> - - } - /> -
    + + {( + marketData.priceMonitoringSettings?.parameters?.triggers || [] + ).map((_, triggerIndex) => ( + <> +

    + {t(`Price monitoring bounds ${triggerIndex + 1}`)} +

    + + + + ))} + +

    + {t('Liquidity monitoring parameters')} +

    + + +

    + {t('Liquidity price range')} +

    +
    )} diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx index 8b0970b3a..dceb58bd9 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx @@ -1,6 +1,7 @@ import { MemoryRouter } from 'react-router-dom'; import { MockedProvider } from '@apollo/client/testing'; import { VegaWalletProvider } from '@vegaprotocol/wallet'; +import type { VegaWalletConfig } from '@vegaprotocol/wallet'; import { render, screen } from '@testing-library/react'; import { generateProposal } from '../../test-helpers/generate-proposals'; import { Proposal } from './proposal'; @@ -37,11 +38,23 @@ jest.mock('../list-asset', () => ({ ListAsset: () =>
    , })); +const vegaWalletConfig: VegaWalletConfig = { + network: 'TESTNET', + vegaUrl: 'https://vega.xyz', + vegaWalletServiceUrl: 'https://wallet.vega.xyz', + links: { + explorer: 'explorer', + concepts: 'concepts', + chromeExtensionUrl: 'chrome', + mozillaExtensionUrl: 'mozilla', + }, +}; + const renderComponent = (proposal: ProposalQuery['proposal']) => { render( - + { disconnect: jest.fn(), selectPubKey: jest.fn(), connector: null, - }; + } as unknown as VegaWalletContextShape; render( diff --git a/apps/governance/src/routes/proposals/propose/raw/proposal-raw.spec.tsx b/apps/governance/src/routes/proposals/propose/raw/proposal-raw.spec.tsx index f9b65c8ca..18fcbaa9f 100644 --- a/apps/governance/src/routes/proposals/propose/raw/proposal-raw.spec.tsx +++ b/apps/governance/src/routes/proposals/propose/raw/proposal-raw.spec.tsx @@ -114,6 +114,7 @@ describe('Raw proposal form', () => { { pubKey, sendTx: mockSendTx, + links: { explorer: 'explorer' }, } as unknown as VegaWalletContextShape } > diff --git a/apps/governance/src/routes/proposals/test-helpers/mocks.ts b/apps/governance/src/routes/proposals/test-helpers/mocks.ts index 25e4e6230..229ae756b 100644 --- a/apps/governance/src/routes/proposals/test-helpers/mocks.ts +++ b/apps/governance/src/routes/proposals/test-helpers/mocks.ts @@ -1,7 +1,7 @@ import { NetworkParamsDocument } from '@vegaprotocol/network-parameters'; import type { MockedResponse } from '@apollo/client/testing'; import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters'; -import type { PubKey } from '@vegaprotocol/wallet'; +import type { PubKey, VegaWalletContextShape } from '@vegaprotocol/wallet'; import type { VoteValue } from '@vegaprotocol/types'; import type { UserVoteQuery } from '../components/vote-details/__generated__/Vote'; import { UserVoteDocument } from '../components/vote-details/__generated__/Vote'; @@ -21,7 +21,7 @@ export const mockWalletContext = { disconnect: jest.fn(), selectPubKey: jest.fn(), connector: null, -}; +} as unknown as VegaWalletContextShape; const mockEthereumConfig = { network_id: '3', diff --git a/apps/trading-e2e/.env b/apps/trading-e2e/.env index f6970c049..a575afdea 100644 --- a/apps/trading-e2e/.env +++ b/apps/trading-e2e/.env @@ -36,4 +36,4 @@ CYPRESS_VEGA_WALLET_API_TOKEN= # Cosmic elevator flags (MUST be doubled with CYPRESS_ prefix) NX_SUCCESSOR_MARKETS=true -CYPRESS_NX_SUCCESSOR_MARKETS=true \ No newline at end of file +CYPRESS_NX_SUCCESSOR_MARKETS=true diff --git a/apps/trading-e2e/src/integration/closed-markets.cy.ts b/apps/trading-e2e/src/integration/closed-markets.cy.ts index d689963d8..f0861de49 100644 --- a/apps/trading-e2e/src/integration/closed-markets.cy.ts +++ b/apps/trading-e2e/src/integration/closed-markets.cy.ts @@ -259,6 +259,12 @@ describe('Closed markets', { tags: '@smoke' }, () => { .find('[data-testid="market-code"]') .should('have.text', settledMarket.tradableInstrument.instrument.code); + // 6001-MARK-071 + cy.get(rowSelector) + .first() + .find('[title="Future"]') + .should('have.text', 'Futr'); + // 6001-MARK-002 cy.get(rowSelector) .first() diff --git a/apps/trading-e2e/src/integration/market-all.cy.ts b/apps/trading-e2e/src/integration/market-all.cy.ts deleted file mode 100644 index 83183f3bc..000000000 --- a/apps/trading-e2e/src/integration/market-all.cy.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { aliasGQLQuery } from '@vegaprotocol/cypress'; -import type { MarketsQuery } from '@vegaprotocol/markets'; -import * as Schema from '@vegaprotocol/types'; - -const rowSelector = - '[data-testid="tab-open-markets"] .ag-center-cols-container .ag-row'; -const colInstrumentCode = - '[col-id="tradableInstrument.instrument.code"] [data-testid="market-code"]'; - -describe('markets all table', { tags: '@smoke' }, () => { - beforeEach(() => { - cy.clearLocalStorage().then(() => { - cy.setOnBoardingViewed(); - cy.mockTradingPage( - Schema.MarketState.STATE_ACTIVE, - Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, - Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY_TARGET_NOT_MET - ); - cy.mockSubscription(); - cy.visit('/#/markets/all'); - }); - }); - - it('can see table headers', () => { - cy.wait('@Markets'); - cy.wait('@MarketsData'); - const headers = [ - 'Market', - 'Description', - 'Trading mode', - 'Status', - 'Successor market', - 'Best bid', - 'Best offer', - 'Mark price', - 'Settlement asset', - '', - ]; - cy.getByTestId('tab-open-markets').within(($headers) => { - cy.wrap($headers) - .get('.ag-header-cell-text') - .each(($header, i) => { - cy.wrap($header).should('have.text', headers[i]); - }); - }); - }); - - it('markets tab should be rendered properly', () => { - cy.get('[data-testid="Open markets"]').should( - 'have.attr', - 'data-state', - 'active' - ); - cy.get('[data-testid="Proposed markets"]').should( - 'have.attr', - 'data-state', - 'inactive' - ); - cy.get('[data-testid="Closed markets"]').should( - 'have.attr', - 'data-state', - 'inactive' - ); - }); - it('renders markets correctly', () => { - // 6001-MARK-035 - cy.get(rowSelector) - .first() - .find(colInstrumentCode) - .should('have.text', 'SOLUSD'); - - // 6001-MARK-036 - cy.get(rowSelector) - .first() - .find('[col-id="tradableInstrument.instrument.name"]') - .should('have.text', 'SUSPENDED MARKET'); - - // 6001-MARK-037 - cy.get(rowSelector) - .first() - .find('[col-id="tradingMode"]') - .should('have.text', 'Continuous'); - - // 6001-MARK-038 - cy.get(rowSelector) - .first() - .find('[col-id="state"]') - .should('have.text', 'Active'); - - // 6001-MARK-039 - cy.get(rowSelector) - .first() - .find('[col-id="data.bestBidPrice"]') - .should('have.text', '0.00'); - - // 6001-MARK-040 - cy.get(rowSelector) - .first() - .find('[col-id="data.bestOfferPrice"]') - .should('have.text', '0.00'); - - // 6001-MARK-041 - cy.get(rowSelector) - .first() - .find('[col-id="data.markPrice"]') - .should('have.text', '84.41'); - - // 6001-MARK-042 - cy.get(rowSelector) - .first() - .find( - '[col-id="tradableInstrument.instrument.product.settlementAsset.symbol"]' - ) - .should('have.text', 'XYZalpha'); - - // 6001-MARK-043 - cy.get(rowSelector) - .first() - .find( - '[col-id="tradableInstrument.instrument.product.settlementAsset.symbol"] button' - ) - .click(); - cy.getByTestId('dialog-title').should('have.text', 'Asset details - tEURO'); - cy.getByTestId('close-asset-details-dialog').click(); - }); - - it('can open row actions', () => { - // 6001-MARK-044 - cy.get('.ag-pinned-right-cols-container') - .find('[col-id="market-actions"]') - .first() - .find('button') - .click(); - - // 6001-MARK-045 - const dropdownContent = '[data-testid="market-actions-content"]'; - const dropdownContentItem = '[role="menuitem"]'; - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(0) - // Cannot click the copy button as it falls back to window.prompt, blocking the test. - .should('have.text', 'Copy Market ID'); - - // 6001-MARK-046 - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(1) - .find('a') - .then(($el) => { - const href = $el.attr('href'); - expect(/\/markets\/market-1/.test(href || '')).to.equal(true); - }) - .should('have.text', 'View on Explorer'); - - // 6001-MARK-047 - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(2) - .should('have.text', 'View settlement asset details'); - cy.getByTestId('market-actions-content').click(); - }); - - it('able to open and sort full market list - market page', () => { - // 6001-MARK-064 - const ExpectedSortedMarkets = [ - 'AAPL.MF21', - 'BTCUSD.MF21', - 'ETHBTC.QM21', - 'SOLUSD', - ]; - cy.get('[data-testid="Open markets"]').click({ force: true }); - cy.url().should('eq', Cypress.config('baseUrl') + '/#/markets/all'); - cy.contains('AAPL.MF21').should('be.visible'); - cy.get('.ag-header-cell-label').contains('Market').click(); // sort by market name - for (let i = 0; i < ExpectedSortedMarkets.length; i++) { - cy.get(`[row-index=${i}]`) - .find(colInstrumentCode) - .should('have.text', ExpectedSortedMarkets[i]); - } - }); - - it('can drag and drop columns', () => { - // 6001-MARK-065 - cy.get('.ag-overlay-loading-wrapper').should('not.be.visible'); - cy.get(colInstrumentCode) - .realMouseDown() - .realMouseMove(700, 15) - .realMouseUp(); - cy.get(colInstrumentCode).should(($element) => { - const attributeValue = $element.attr('aria-colindex'); - expect(attributeValue).not.to.equal('1'); - }); - }); -}); - -describe('no open markets', { tags: '@smoke', testIsolation: true }, () => { - before(() => { - cy.mockTradingPage(); - const markets: MarketsQuery = {}; - cy.mockGQL((req) => { - aliasGQLQuery(req, 'Markets', markets); - }); - cy.mockSubscription(); - cy.setOnBoardingViewed(); - cy.visit('/#/markets/all'); - }); - - it.skip('can see no markets message', () => { - // 6001-MARK-048 - cy.getByTestId('tab-open-markets').should('contain.text', 'No markets'); - }); -}); diff --git a/apps/trading-e2e/src/integration/markets-proposed.cy.ts b/apps/trading-e2e/src/integration/markets-proposed.cy.ts deleted file mode 100644 index 5c8057477..000000000 --- a/apps/trading-e2e/src/integration/markets-proposed.cy.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { aliasGQLQuery, checkSorting } from '@vegaprotocol/cypress'; -import type { ProposalsListQuery } from '@vegaprotocol/proposals'; - -const rowSelector = - '[data-testid="tab-proposed-markets"] .ag-center-cols-container .ag-row'; -const colMarketId = '[col-id="market"] [data-testid="market-code"]'; - -describe('markets proposed table', { tags: '@smoke' }, () => { - before(() => { - cy.mockTradingPage(); - cy.mockSubscription(); - cy.setOnBoardingViewed(); - cy.visit('/#/markets/all'); - cy.get('[data-testid="Proposed markets"]').click(); - }); - - it('can see table headers', () => { - const headers = [ - 'Market', - 'Description', - 'Settlement asset', - 'State', - 'Parent market', - 'Voting', - 'Closing date', - 'Enactment date', - '', - ]; - cy.getByTestId('tab-proposed-markets').within(($headers) => { - cy.wrap($headers) - .get('.ag-header-cell-text') - .each(($header, i) => { - cy.wrap($header).should('have.text', headers[i]); - }); - }); - }); - - it('renders markets correctly', () => { - // 6001-MARK-049 - cy.get(rowSelector).first().find(colMarketId).should('have.text', 'ETHUSD'); - - // 6001-MARK-050 - cy.get(rowSelector) - .first() - .find('[col-id="description"]') - .should('have.text', 'ETHUSD'); - - // 6001-MARK-051 - cy.get(rowSelector) - .first() - .find('[col-id="asset"]') - .should('have.text', 'tDAI TEST'); - - // 6001-MARK-052 - // 6001-MARK-053 - cy.get(rowSelector) - .first() - .find('[col-id="state"]') - .should('have.text', 'Open'); - - // 6001-MARK-054 - cy.get(rowSelector) - .first() - .find('[col-id="voting"]') - .should('have.text', ''); - - // 6001-MARK-056 - cy.get(rowSelector) - .first() - .find('[col-id="closing-date"]') - .should('not.be.empty'); - - // 6001-MARK-057 - cy.get(rowSelector) - .first() - .find('[col-id="enactment-date"]') - .should('not.be.empty'); - }); - - it('can open row actions', () => { - // 6001-MARK-058 - cy.get('.ag-pinned-right-cols-container') - .find('[col-id="proposal-actions"]') - .first() - .find('button') - .click(); - - const dropdownContent = '[data-testid="proposal-actions-content"]'; - const dropdownContentItem = '[role="menuitem"]'; - - // 6001-MARK-059 - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(0) - .find('a') - .should('have.text', 'View proposal') - .and( - 'have.attr', - 'href', - `${Cypress.env( - 'VEGA_TOKEN_URL' - )}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829` - ); - }); - - // 6001-MARK-060 - it('can see proposed market link', () => { - cy.getByTestId('tab-proposed-markets') - .find('[data-testid="external-link"]') - .should('have.length', 11) - .last() - .should('have.text', 'Propose a new market') - .and( - 'have.attr', - 'href', - `${Cypress.env('VEGA_TOKEN_URL')}/proposals/propose/new-market` - ); - }); - it('proposed markets tab should be sorted properly', () => { - // 6001-MARK-062 - cy.get('[data-testid="Proposed markets"]').click({ force: true }); - const marketColDefault = [ - 'ETHUSD', - 'LINKUSD', - 'ETHUSD', - 'ETHDAI.MF21', - 'AAPL.MF21', - 'BTCUSD.MF21', - 'TSLA.QM21', - 'AAVEDAI.MF21', - 'ETHBTC.QM21', - 'UNIDAI.MF21', - ]; - const marketColAsc = [ - 'AAPL.MF21', - 'AAVEDAI.MF21', - 'BTCUSD.MF21', - 'ETHBTC.QM21', - 'ETHDAI.MF21', - 'ETHUSD', - 'ETHUSD', - 'LINKUSD', - 'TSLA.QM21', - 'UNIDAI.MF21', - ]; - const marketColDesc = [ - 'UNIDAI.MF21', - 'TSLA.QM21', - 'LINKUSD', - 'ETHUSD', - 'ETHUSD', - 'ETHDAI.MF21', - 'ETHBTC.QM21', - 'BTCUSD.MF21', - 'AAVEDAI.MF21', - 'AAPL.MF21', - ]; - checkSorting( - 'market', - marketColDefault, - marketColAsc, - marketColDesc, - ' [data-testid="market-code"]' - ); - - const stateColDefault = [ - 'Open', - 'Passed', - 'Waiting for Node Vote', - 'Open', - 'Passed', - 'Open', - 'Passed', - 'Open', - 'Waiting for Node Vote', - 'Open', - ]; - const stateColAsc = [ - 'Open', - 'Open', - 'Open', - 'Open', - 'Open', - 'Passed', - 'Passed', - 'Passed', - 'Waiting for Node Vote', - 'Waiting for Node Vote', - ]; - const stateColDesc = [ - 'Waiting for Node Vote', - 'Waiting for Node Vote', - 'Passed', - 'Passed', - 'Passed', - 'Open', - 'Open', - 'Open', - 'Open', - 'Open', - ]; - checkSorting('state', stateColDefault, stateColAsc, stateColDesc); - }); - - it('can drag and drop columns', () => { - // 6001-MARK-063 - cy.get(colMarketId).realMouseDown().realMouseMove(700, 15).realMouseUp(); - cy.get(colMarketId).should(($element) => { - const attributeValue = $element.attr('aria-colindex'); - expect(attributeValue).not.to.equal('1'); - }); - }); -}); - -describe('no markets proposed', { tags: '@smoke', testIsolation: true }, () => { - before(() => { - cy.mockTradingPage(); - const proposal: ProposalsListQuery = {}; - cy.mockGQL((req) => { - aliasGQLQuery(req, 'ProposalsList', proposal); - }); - cy.mockSubscription(); - cy.setOnBoardingViewed(); - }); - - it.skip('can see no markets message', () => { - cy.visit('/#/markets/all'); - cy.get('[data-testid="Proposed markets"]').click(); - - // 6001-MARK-061 - cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets'); - }); -}); diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-basic-submit.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-basic-submit.cy.ts deleted file mode 100644 index a5c6df052..000000000 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-basic-submit.cy.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as Schema from '@vegaprotocol/types'; -import { testOrderSubmission } from '../support/order-validation'; -import type { OrderSubmission } from '@vegaprotocol/wallet'; -import { createOrder } from '../support/create-order'; - -describe.skip('must submit order', { tags: '@smoke' }, () => { - // 7002-SORD-039 - before(() => { - cy.setVegaWallet(); - cy.mockTradingPage(); - cy.mockSubscription(); - cy.visit('/#/markets/market-0'); - cy.wait('@Markets'); - }); - - beforeEach(() => { - cy.setVegaWallet(); - }); - - it('successfully places market buy order', () => { - // 7002-SORD-010 - // 0003-WTXN-012 - // 0003-WTXN-003 - cy.mockVegaWalletTransaction(); - const order: OrderSubmission = { - marketId: 'market-0', - type: Schema.OrderType.TYPE_MARKET, - side: Schema.Side.SIDE_BUY, - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, - postOnly: false, - reduceOnly: false, - size: '100', - }; - createOrder(order); - testOrderSubmission(order); - }); - - it('successfully places market sell order', () => { - cy.mockVegaWalletTransaction(); - const order: OrderSubmission = { - marketId: 'market-0', - type: Schema.OrderType.TYPE_MARKET, - side: Schema.Side.SIDE_SELL, - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, - size: '100', - postOnly: false, - reduceOnly: false, - }; - createOrder(order); - testOrderSubmission(order); - }); - - it('successfully places limit buy order', () => { - // 7002-SORD-017 - cy.mockVegaWalletTransaction(); - const order: OrderSubmission = { - marketId: 'market-0', - type: Schema.OrderType.TYPE_LIMIT, - side: Schema.Side.SIDE_BUY, - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, - size: '100', - postOnly: false, - reduceOnly: false, - price: '200', - }; - createOrder(order); - testOrderSubmission(order, { price: '20000000' }); - }); - - it('successfully places limit sell order', () => { - cy.mockVegaWalletTransaction(); - const order: OrderSubmission = { - marketId: 'market-0', - type: Schema.OrderType.TYPE_LIMIT, - side: Schema.Side.SIDE_SELL, - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GFN, - size: '100', - postOnly: false, - reduceOnly: false, - price: '50000', - }; - createOrder(order); - testOrderSubmission(order, { price: '5000000000' }); - }); - - it('successfully places GTT limit buy order', () => { - cy.mockVegaWalletTransaction(); - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const order: OrderSubmission = { - marketId: 'market-0', - type: Schema.OrderType.TYPE_LIMIT, - side: Schema.Side.SIDE_SELL, - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT, - size: '100', - price: '1.00', - expiresAt: expiresAt.toISOString().substring(0, 16), - postOnly: false, - reduceOnly: false, - }; - - createOrder(order); - testOrderSubmission(order, { - price: '100000', - expiresAt: - new Date(order.expiresAt as string).getTime().toString() + '000000', - postOnly: false, - reduceOnly: false, - }); - }); -}); diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts index 0b2d1036a..b892ca01a 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts @@ -23,7 +23,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => { // 0003-WTXN-001 cy.getByTestId('connect-vega-wallet'); // Not connected cy.getByTestId(placeOrderBtn).should('exist'); - cy.getByTestId('get-started-button').should('exist'); + cy.getByTestId('order-connect-wallet').should('exist'); }); it('must be able to select order direction - long/short', function () { @@ -44,7 +44,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => { mockConnectWallet(); cy.getByTestId(toggleLimit).click(); cy.getByTestId(orderPriceField).clear().type('101'); - cy.getByTestId('get-started-button').click(); + cy.getByTestId('order-connect-wallet').click(); cy.getByTestId('dialog-content').should('be.visible'); cy.getByTestId('connectors-list') .find('[data-testid="connector-jsonRpc"]') diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts index 7de0dcb36..fe82aee3e 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts @@ -33,9 +33,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { it('must see the price unit', function () { // 7002-SORD-018 - cy.getByTestId(orderPriceField) - .siblings('label') - .should('have.text', 'Price (DAI)'); + cy.getByTestId(orderPriceField).next().should('have.text', 'DAI'); }); it('must see warning when placing an order with expiry date in past', () => { @@ -64,7 +62,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { cy.getByTestId(orderSizeField).clear().type('1'); cy.getByTestId(orderPriceField).clear().type('1.123456'); cy.getByTestId(placeOrderBtn).click(); - cy.getByTestId('deal-ticket-error-message-price-limit').should( + cy.getByTestId('deal-ticket-error-message-price').should( 'have.text', 'Price accepts up to 5 decimal places' ); @@ -87,7 +85,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { cy.getByTestId(orderSizeField).clear().type('1.234'); // 7002-SORD-060 cy.getByTestId(placeOrderBtn).should('be.enabled'); - cy.getByTestId('deal-ticket-error-message-size-market').should( + cy.getByTestId('deal-ticket-error-message-size').should( 'have.text', 'Size must be whole numbers for this market' ); @@ -96,7 +94,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { it('must warn if order size is set to 0', function () { cy.getByTestId(orderSizeField).clear().type('0'); cy.getByTestId(placeOrderBtn).should('be.enabled'); - cy.getByTestId('deal-ticket-error-message-size-market').should( + cy.getByTestId('deal-ticket-error-message-size').should( 'have.text', 'Size cannot be lower than 1' ); diff --git a/apps/trading-e2e/src/integration/trading-orders.cy.ts b/apps/trading-e2e/src/integration/trading-orders.cy.ts index 31ceb2818..c595f5aab 100644 --- a/apps/trading-e2e/src/integration/trading-orders.cy.ts +++ b/apps/trading-e2e/src/integration/trading-orders.cy.ts @@ -222,12 +222,17 @@ describe('subscribe orders', { tags: '@smoke' }, () => { it('must see a filled order', () => { // 7002-SORD-046 + // 7003-MORD-020 // NOT COVERED: Must be able to see/link to all trades that were created from this order updateOrder({ id: orderId, status: Schema.OrderStatus.STATUS_FILLED, }); cy.getByTestId(`order-status-${orderId}`).should('have.text', 'Filled'); + cy.get('[col-id="market.tradableInstrument.instrument.code"]').contains( + '[title="Future"]', + 'Futr' + ); }); it('must see a rejected order', () => { diff --git a/apps/trading-e2e/src/integration/trading-portfolio.cy.ts b/apps/trading-e2e/src/integration/trading-portfolio.cy.ts index 6fe9208c9..d9789117d 100644 --- a/apps/trading-e2e/src/integration/trading-portfolio.cy.ts +++ b/apps/trading-e2e/src/integration/trading-portfolio.cy.ts @@ -1,59 +1,25 @@ import { aliasGQLQuery } from '@vegaprotocol/cypress'; -import { ledgerEntriesQuery } from '@vegaprotocol/mock'; +import { partyAssetsQuery } from '@vegaprotocol/mock'; describe('Portfolio page', { tags: '@smoke' }, () => { beforeEach(() => { - cy.mockTradingPage(); cy.mockGQL((req) => { - aliasGQLQuery(req, 'LedgerEntries', ledgerEntriesQuery()); + aliasGQLQuery(req, 'PartyAssets', partyAssetsQuery()); }); + cy.mockTradingPage(); cy.mockSubscription(); cy.setVegaWallet(); }); describe('Ledger entries', () => { - it('List should be properly rendered', () => { + it('Download form should be properly rendered', () => { + // 7007-LEEN-001 cy.visit('/#/portfolio'); cy.getByTestId('"Ledger entries"').click(); - const headers = [ - 'Sender', - 'Account type', - 'Market', - 'Receiver', - 'Account type', - 'Market', - 'Transfer type', - 'Quantity', - 'Asset', - 'Sender account balance', - 'Receiver account balance', - 'Vega time', - ]; cy.getByTestId('tab-ledger-entries').within(($headers) => { cy.wrap($headers) - .get('.ag-header-cell-text') - .each(($header, i) => { - cy.wrap($header).should('have.text', headers[i]); - }); + .getByTestId('ledger-download-button') + .should('be.visible'); }); - cy.get( - '[data-testid="tab-ledger-entries"] .ag-center-cols-container .ag-row' - ).should('have.length', ledgerEntriesQuery().ledgerEntries.edges.length); - }); - - it('account filters should be callable', () => { - cy.visit('/#/portfolio'); - cy.getByTestId('"Ledger entries"').click(); - cy.get('[role="columnheader"][col-id="fromAccountType"]').realHover(); - cy.get( - '[role="columnheader"][col-id="fromAccountType"] .ag-header-cell-menu-button' - ).click(); - cy.get('fieldset.ag-simple-filter-body-wrapper') - .should('be.visible') - .within((fields) => { - cy.wrap(fields).find('label').should('have.length', 18); - }); - cy.getByTestId('"Ledger entries"').click(); - cy.get('fieldset.ag-simple-filter-body-wrapper').should('not.exist'); }); }); }); diff --git a/apps/trading/.env b/apps/trading/.env index bd619e109..fbd2a537f 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -15,11 +15,13 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true NX_TENDERMINT_URL=https://tm.n01.stagnet1.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.stagnet1.vega.xyz/websocket diff --git a/apps/trading/.env.capsule b/apps/trading/.env.capsule index d2a3ef8be..4638a051a 100644 --- a/apps/trading/.env.capsule +++ b/apps/trading/.env.capsule @@ -12,6 +12,8 @@ NX_VEGA_WALLET_URL=http://localhost:1789 NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/test/announcements.json +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet NX_ETH_LOCAL_PROVIDER_URL=http://localhost:8545/ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supply green clown session" @@ -21,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=http://localhost:26617 NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26617/websocket diff --git a/apps/trading/.env.devnet b/apps/trading/.env.devnet index 52112f1bf..9462cb56c 100644 --- a/apps/trading/.env.devnet +++ b/apps/trading/.env.devnet @@ -13,12 +13,15 @@ NX_VEGA_DOCS_URL=# NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega-dev-releases/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true NX_TENDERMINT_URL=https://tm.be.devnet1.vega.xyz/ NX_TENDERMINT_WEBSOCKET_URL=wss://be.devnet1.vega.xyz/websocket diff --git a/apps/trading/.env.mainnet b/apps/trading/.env.mainnet index 134a47026..44e6c8447 100644 --- a/apps/trading/.env.mainnet +++ b/apps/trading/.env.mainnet @@ -13,6 +13,8 @@ NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/mainnet/announcements.json NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports NX_VEGA_CONSOLE_URL=https://console.vega.xyz +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-mainnet/codfcglpplgmmlokgilfkpcjnmkbfiel +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet-mainnet # TAG name of the current app version - TODO: bump to the latest upon release NX_APP_VERSION=v0.20.21-core-0.71.6 @@ -21,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://be.vega.community NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket diff --git a/apps/trading/.env.mainnet-mirror b/apps/trading/.env.mainnet-mirror index a68b8e1e4..aebcf5e35 100644 --- a/apps/trading/.env.mainnet-mirror +++ b/apps/trading/.env.mainnet-mirror @@ -13,6 +13,8 @@ NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/mainnet/announcements.json NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports NX_VEGA_CONSOLE_URL=https://console.mainnet-mirror.vega.rocks +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-mainnet/codfcglpplgmmlokgilfkpcjnmkbfiel +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet-mainnet # TAG name of the current app version - TODO: bump to the latest upon release NX_APP_VERSION=v0.20.19-core-0.71.6 @@ -21,6 +23,7 @@ NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://be.mainnet-mirror.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.mainnet-mirror.vega.rocks/websocket diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index 7a75a6ee0..0294d0d0b 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -13,9 +13,12 @@ NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS -# NX_PRODUCT_PERPETUALS \ No newline at end of file +# NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=true diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index 5958b71b6..c673562b1 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -14,12 +14,15 @@ NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports NX_VEGA_CONSOLE_URL=https://console.fairground.wtf +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true NX_ICEBERG_ORDERS=true # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz -NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket \ No newline at end of file +NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket diff --git a/apps/trading/.env.validators-testnet b/apps/trading/.env.validators-testnet index 2461252a1..c31b364b1 100644 --- a/apps/trading/.env.validators-testnet +++ b/apps/trading/.env.validators-testnet @@ -15,11 +15,15 @@ NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/annou NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports NX_VEGA_CONSOLE_URL=https://trading.validators-testnet.vega.rocks +NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn +NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/pl/firefox/addon/vega-wallet + # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS +NX_METAMASK_SNAPS=false NX_TENDERMINT_URL=https://tm.be.validators-testnet.vega.rocks NX_TENDERMINT_WEBSOCKET_URL=wss://be.validators-testnet.vega.xyz/websocket diff --git a/apps/trading/components/app-loader/app-loader.tsx b/apps/trading/components/app-loader/app-loader.tsx index dc08f4895..d2bc94930 100644 --- a/apps/trading/components/app-loader/app-loader.tsx +++ b/apps/trading/components/app-loader/app-loader.tsx @@ -1,6 +1,7 @@ import type { InMemoryCacheConfig } from '@apollo/client'; import { AppFailure, + DocsLinks, NetworkLoader, NodeGuard, useEnvironment, @@ -17,16 +18,32 @@ export const DynamicLoader = dynamic(() => import('../preloader/preloader'), { }); export const AppLoader = ({ children }: { children: ReactNode }) => { - const { error, VEGA_URL, MAINTENANCE_PAGE } = useEnvironment((store) => ({ - error: store.error, - VEGA_URL: store.VEGA_URL, - MAINTENANCE_PAGE: store.MAINTENANCE_PAGE, - })); + const { + error, + VEGA_URL, + VEGA_ENV, + VEGA_WALLET_URL, + VEGA_EXPLORER_URL, + MAINTENANCE_PAGE, + MOZILLA_EXTENSION_URL, + CHROME_EXTENSION_URL, + } = useEnvironment(); if (MAINTENANCE_PAGE) { return ; } + if ( + !VEGA_URL || + !VEGA_WALLET_URL || + !VEGA_EXPLORER_URL || + !CHROME_EXTENSION_URL || + !MOZILLA_EXTENSION_URL || + !DocsLinks + ) { + return null; + } + return ( { failure={} > - {children} + + {children} + diff --git a/apps/trading/components/constants.ts b/apps/trading/components/constants.ts index f1168e6ce..4e9781d22 100644 --- a/apps/trading/components/constants.ts +++ b/apps/trading/components/constants.ts @@ -1,10 +1,2 @@ -import { t } from '@vegaprotocol/i18n'; - export const THROTTLE_UPDATE_TIME = 500; export const ONBOARDING_VIEWED_KEY = 'vega_onboarding_viewed'; -export const MAINNET_WELCOME_HEADER = t( - 'Trade cash settled futures on the fully decentralised Vega network.' -); -export const TESTNET_WELCOME_HEADER = t( - 'Try out trading cash settled futures on the fully decentralised Vega network (Testnet).' -); diff --git a/apps/trading/components/ledger-container/ledger-container.tsx b/apps/trading/components/ledger-container/ledger-container.tsx index 25977711d..36bddc043 100644 --- a/apps/trading/components/ledger-container/ledger-container.tsx +++ b/apps/trading/components/ledger-container/ledger-container.tsx @@ -1,23 +1,30 @@ -import { useDataGridEvents } from '@vegaprotocol/datagrid'; import { t } from '@vegaprotocol/i18n'; -import { LedgerManager } from '@vegaprotocol/ledger'; -import { Splash } from '@vegaprotocol/ui-toolkit'; +import { LedgerExportForm } from '@vegaprotocol/ledger'; +import { Loader, Splash } from '@vegaprotocol/ui-toolkit'; import { useVegaWallet } from '@vegaprotocol/wallet'; -import type { DataGridSlice } from '../../stores/datagrid-store-slice'; -import { createDataGridSlice } from '../../stores/datagrid-store-slice'; -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { useEnvironment } from '@vegaprotocol/environment'; +import type { PartyAssetFieldsFragment } from '@vegaprotocol/assets'; +import { usePartyAssetsQuery } from '@vegaprotocol/assets'; export const LedgerContainer = () => { + const VEGA_URL = useEnvironment((store) => store.VEGA_URL); const { pubKey } = useVegaWallet(); - - const gridStore = useLedgerStore((store) => store.gridStore); - const updateGridStore = useLedgerStore((store) => store.updateGridStore); - - const gridStoreCallbacks = useDataGridEvents(gridStore, (colState) => { - updateGridStore(colState); + const { data, loading } = usePartyAssetsQuery({ + variables: { partyId: pubKey || '' }, + skip: !pubKey, }); + const assets = (data?.party?.accountsConnection?.edges ?? []) + .map( + (item) => item?.node?.asset ?? ({} as PartyAssetFieldsFragment) + ) + .reduce((aggr, item) => { + if ('id' in item && 'symbol' in item) { + aggr[item.id as string] = item.symbol as string; + } + return aggr; + }, {} as Record); + if (!pubKey) { return ( @@ -26,11 +33,31 @@ export const LedgerContainer = () => { ); } - return ; -}; + if (!VEGA_URL) { + return ( + +

    {t('Environment not configured')}

    +
    + ); + } -const useLedgerStore = create()( - persist(createDataGridSlice, { - name: 'vega_ledger_store', - }) -); + if (loading) { + return ( +
    + +
    + ); + } + + if (!Object.keys(assets).length) { + return ( + +

    {t('No ledger entries to export')}

    +
    + ); + } + + return ( + + ); +}; diff --git a/apps/trading/components/market-banner/market-successor-proposal-banner.tsx b/apps/trading/components/market-banner/market-successor-proposal-banner.tsx index 68104fe98..8797909bf 100644 --- a/apps/trading/components/market-banner/market-successor-proposal-banner.tsx +++ b/apps/trading/components/market-banner/market-successor-proposal-banner.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import type { SuccessorProposalListFieldsFragment, NewMarketSuccessorFieldsFragment, @@ -52,7 +52,7 @@ export const MarketSuccessorProposalBanner = ({ TOKEN_PROPOSAL.replace(':id', item.id || '') ); return ( - <> + { (item.terms?.change as NewMarketSuccessorFieldsFragment) @@ -60,7 +60,7 @@ export const MarketSuccessorProposalBanner = ({ } {i < successors.length - 1 && ', '} - + ); })}
    diff --git a/apps/trading/components/market-header/market-header.tsx b/apps/trading/components/market-header/market-header.tsx index fe7f684b2..178f67e9d 100644 --- a/apps/trading/components/market-header/market-header.tsx +++ b/apps/trading/components/market-header/market-header.tsx @@ -3,7 +3,7 @@ import { Header, HeaderTitle } from '../header'; import { useParams } from 'react-router-dom'; import { MarketSelector } from '../../components/market-selector/market-selector'; import { MarketHeaderStats } from '../../client-pages/market/market-header-stats'; -import { useMarket } from '@vegaprotocol/markets'; +import { useMarket, useMarketList } from '@vegaprotocol/markets'; import { useState } from 'react'; export const MarketHeader = () => { @@ -11,6 +11,10 @@ export const MarketHeader = () => { const { data } = useMarket(marketId); const [open, setOpen] = useState(false); + // Ensure that markets are kept cached so opening the list + // shows all markets instantly + useMarketList(); + if (!data) return null; return ( diff --git a/apps/trading/components/market-selector/market-selector.spec.tsx b/apps/trading/components/market-selector/market-selector.spec.tsx index a3fa3c5c8..faf20a8ff 100644 --- a/apps/trading/components/market-selector/market-selector.spec.tsx +++ b/apps/trading/components/market-selector/market-selector.spec.tsx @@ -132,6 +132,7 @@ describe('MarketSelector', () => { data: markets, loading: false, error: undefined, + reload: jest.fn(), }); it('Button "All" should be selected by default', () => { diff --git a/apps/trading/components/market-selector/market-selector.tsx b/apps/trading/components/market-selector/market-selector.tsx index 59ec08fc5..7750ea33d 100644 --- a/apps/trading/components/market-selector/market-selector.tsx +++ b/apps/trading/components/market-selector/market-selector.tsx @@ -1,6 +1,6 @@ import { t } from '@vegaprotocol/i18n'; import uniqBy from 'lodash/uniqBy'; -import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets'; +import { type MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets'; import { TradingInput, TinyScroll, @@ -8,7 +8,7 @@ import { VegaIconNames, } from '@vegaprotocol/ui-toolkit'; import type { CSSProperties } from 'react'; -import { useCallback, useState, useMemo, useRef } from 'react'; +import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { FixedSizeList } from 'react-window'; import { useMarketSelectorList } from './use-market-selector-list'; import type { ProductType } from './product-selector'; @@ -44,7 +44,12 @@ export const MarketSelector = ({ assets: [], }); const allProducts = filter.product === Product.All; - const { markets, data, loading, error } = useMarketSelectorList(filter); + const { markets, data, loading, error, reload } = + useMarketSelectorList(filter); + + useEffect(() => { + reload(); + }, [reload]); return (
    diff --git a/apps/trading/components/market-selector/sort-dropdown.tsx b/apps/trading/components/market-selector/sort-dropdown.tsx index fbada5c9f..82e6382ea 100644 --- a/apps/trading/components/market-selector/sort-dropdown.tsx +++ b/apps/trading/components/market-selector/sort-dropdown.tsx @@ -15,6 +15,7 @@ export const Sort = { Gained: 'Gained', Lost: 'Lost', New: 'New', + TopTraded: 'TopTraded', } as const; export type SortType = keyof typeof Sort; @@ -26,6 +27,7 @@ export const SortTypeMapping: { [Sort.Gained]: 'Top gaining', [Sort.Lost]: 'Top losing', [Sort.New]: 'New markets', + [Sort.TopTraded]: 'Top traded', }; const SortIconMapping: { @@ -35,6 +37,7 @@ const SortIconMapping: { [Sort.Gained]: VegaIconNames.TREND_UP, [Sort.Lost]: VegaIconNames.TREND_DOWN, [Sort.New]: VegaIconNames.STAR, + [Sort.TopTraded]: VegaIconNames.ARROW_UP, }; export const SortDropdown = ({ diff --git a/apps/trading/components/market-selector/use-market-selector-list.ts b/apps/trading/components/market-selector/use-market-selector-list.ts index a9626fd66..72bef8f54 100644 --- a/apps/trading/components/market-selector/use-market-selector-list.ts +++ b/apps/trading/components/market-selector/use-market-selector-list.ts @@ -1,7 +1,11 @@ import { useMemo } from 'react'; import orderBy from 'lodash/orderBy'; import { MarketState } from '@vegaprotocol/types'; -import { calcCandleVolume, useMarketList } from '@vegaprotocol/markets'; +import { + calcCandleVolume, + calcTradedFactor, + useMarketList, +} from '@vegaprotocol/markets'; import { priceChangePercentage } from '@vegaprotocol/utils'; import type { Filter } from '../../components/market-selector/market-selector'; import { Sort } from './sort-dropdown'; @@ -20,7 +24,7 @@ export const useMarketSelectorList = ({ sort, searchTerm, }: Filter) => { - const { data, loading, error } = useMarketList(); + const { data, loading, error, reload } = useMarketList(); const markets = useMemo(() => { if (!data?.length) return []; @@ -94,10 +98,14 @@ export const useMarketSelectorList = ({ ); } + if (sort === Sort.TopTraded) { + return orderBy(markets, [(m) => calcTradedFactor(m)], ['desc']); + } + return markets; }, [data, product, searchTerm, assets, sort]); - return { markets, data, loading, error }; + return { markets, data, loading, error, reload }; }; export const isMarketActive = (state: MarketState) => { diff --git a/apps/trading/components/navbar/nav-header.tsx b/apps/trading/components/navbar/nav-header.tsx index ed78eacb1..da12b118a 100644 --- a/apps/trading/components/navbar/nav-header.tsx +++ b/apps/trading/components/navbar/nav-header.tsx @@ -1,6 +1,6 @@ import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit'; import { MarketSelector } from '../market-selector'; -import { useMarket } from '@vegaprotocol/markets'; +import { useMarket, useMarketList } from '@vegaprotocol/markets'; import { t } from '@vegaprotocol/i18n'; import { useParams } from 'react-router-dom'; import * as PopoverPrimitive from '@radix-ui/react-popover'; @@ -14,6 +14,10 @@ export const NavHeader = () => { const { data } = useMarket(marketId); const [open, setOpen] = useState(false); + // Ensure that markets are kept cached so opening the list + // shows all markets instantly + useMarketList(); + if (!marketId) return null; return ( diff --git a/apps/trading/components/sidebar/sidebar.spec.tsx b/apps/trading/components/sidebar/sidebar.spec.tsx index f9fe36511..5867d6525 100644 --- a/apps/trading/components/sidebar/sidebar.spec.tsx +++ b/apps/trading/components/sidebar/sidebar.spec.tsx @@ -9,7 +9,8 @@ import { } from './sidebar'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { VegaIconNames } from '@vegaprotocol/ui-toolkit'; -import { VegaWalletProvider } from '@vegaprotocol/wallet'; +import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; +import { VegaWalletContext } from '@vegaprotocol/wallet'; jest.mock('../node-health', () => ({ NodeHealthContainer: () => , @@ -31,16 +32,20 @@ jest.mock('../welcome-dialog', () => ({ GetStarted: () =>
    , })); +const walletContext = { + pubKeys: [{ publicKey: 'pubkey' }], +} as VegaWalletContextShape; + describe('Sidebar', () => { it.each(['/markets/all', '/portfolio'])( 'does not render ticket and info', (path) => { render( - + - + ); expect(screen.getByTestId(ViewType.ViewAs)).toBeInTheDocument(); @@ -58,11 +63,11 @@ describe('Sidebar', () => { it('renders ticket and info on market pages', () => { render( - + - + ); expect(screen.getByTestId(ViewType.ViewAs)).toBeInTheDocument(); @@ -79,11 +84,11 @@ describe('Sidebar', () => { it('renders selected state', async () => { render( - + - + ); const settingsButton = screen.getByTestId(ViewType.Settings); @@ -107,13 +112,13 @@ describe('Sidebar', () => { describe('SidebarContent', () => { it('renders the correct content', () => { const { container } = render( - + } /> - + ); expect(container).toBeEmptyDOMElement(); @@ -133,13 +138,13 @@ describe('SidebarContent', () => { it('closes sidebar if market id is required but not present', () => { const { container } = render( - + } /> - + ); act(() => { diff --git a/apps/trading/components/stop-orders-container/stop-orders-container.tsx b/apps/trading/components/stop-orders-container/stop-orders-container.tsx index 9dbfed11f..b2e45b93d 100644 --- a/apps/trading/components/stop-orders-container/stop-orders-container.tsx +++ b/apps/trading/components/stop-orders-container/stop-orders-container.tsx @@ -36,6 +36,6 @@ export const StopOrdersContainer = () => { const useStopOrdersStore = create()( persist(createDataGridSlice, { - name: 'vega_fills_store', + name: 'vega_stop_orders_store', }) ); diff --git a/apps/trading/components/welcome-dialog/get-started.spec.tsx b/apps/trading/components/welcome-dialog/get-started.spec.tsx index b8fcf6865..01ff2dbdf 100644 --- a/apps/trading/components/welcome-dialog/get-started.spec.tsx +++ b/apps/trading/components/welcome-dialog/get-started.spec.tsx @@ -1,16 +1,36 @@ +import { MemoryRouter } from 'react-router-dom'; import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet'; import { GetStarted } from './get-started'; import { render, screen } from '@testing-library/react'; +let mockStep = 1; +jest.mock('./use-get-onboarding-step', () => ({ + ...jest.requireActual('./use-get-onboarding-step'), + useGetOnboardingStep: jest.fn(() => mockStep), +})); + describe('GetStarted', () => { const renderComponent = (context: Partial = {}) => { return render( - - - + + + + + ); }; + const checkTicks = (elements: Element[]) => { + elements.forEach((item, i) => { + if (i + 1 < mockStep) { + expect(item.querySelector('[data-testid="icon-tick"]')).toBeTruthy(); + } + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); it('renders full get started content if not connected and no browser wallet detected', () => { renderComponent(); @@ -20,12 +40,71 @@ describe('GetStarted', () => { it('renders connect prompt if no pubKey but wallet installed', () => { globalThis.window.vega = {} as Vega; renderComponent(); - expect(screen.getByTestId('order-connect-wallet')).toBeInTheDocument(); + expect(screen.getByTestId('get-started-banner')).toBeInTheDocument(); globalThis.window.vega = undefined as unknown as Vega; }); it('renders nothing if connected', () => { + mockStep = 0; const { container } = renderComponent({ pubKey: 'my-pubkey' }); expect(container).toBeEmptyDOMElement(); }); + + it('steps should be ticked', () => { + const navigatorGetter: jest.SpyInstance = jest.spyOn( + window.navigator, + 'userAgent', + 'get' + ); + navigatorGetter.mockReturnValue('Chrome'); + mockStep = 1; + const { rerender, container } = renderComponent(); + expect(screen.queryByTestId('icon-tick')).not.toBeInTheDocument(); + expect(screen.getByTestId('get-wallet-button')).toBeInTheDocument(); + + mockStep = 2; + rerender( + + + + + + ); + checkTicks(screen.getAllByRole('listitem')); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + + mockStep = 3; + rerender( + + + + + + ); + checkTicks(screen.getAllByRole('listitem')); + expect(screen.getByRole('button', { name: 'Deposit' })).toBeInTheDocument(); + + mockStep = 4; + rerender( + + + + + + ); + checkTicks(screen.getAllByRole('listitem')); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); + + mockStep = 5; + rerender( + + + + + + ); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/apps/trading/components/welcome-dialog/get-started.tsx b/apps/trading/components/welcome-dialog/get-started.tsx index 3889a7662..59c0099ee 100644 --- a/apps/trading/components/welcome-dialog/get-started.tsx +++ b/apps/trading/components/welcome-dialog/get-started.tsx @@ -1,36 +1,103 @@ import classNames from 'classnames'; import { t } from '@vegaprotocol/i18n'; -import { ExternalLink, Intent, TradingButton } from '@vegaprotocol/ui-toolkit'; import { + ExternalLink, + Intent, + TradingButton, + VegaIcon, + VegaIconNames, +} from '@vegaprotocol/ui-toolkit'; +import { + GetWalletButton, useVegaWallet, useVegaWalletDialogStore, - isBrowserWalletInstalled, } from '@vegaprotocol/wallet'; import { Networks, useEnvironment } from '@vegaprotocol/environment'; import { useLocalStorage } from '@vegaprotocol/react-helpers'; +import { useNavigate } from 'react-router-dom'; +import { + OnboardingStep, + useGetOnboardingStep, +} from './use-get-onboarding-step'; +import { Links, Routes } from '../../pages/client-router'; +import { useGlobalStore } from '../../stores'; +import { useSidebar, ViewType } from '../sidebar'; import * as constants from '../constants'; interface Props { lead?: string; } -export const GetStarted = ({ lead }: Props) => { - const { pubKey } = useVegaWallet(); - const { VEGA_ENV, VEGA_NETWORKS } = useEnvironment(); - const CANONICAL_URL = VEGA_NETWORKS[VEGA_ENV] || 'https://console.vega.xyz'; - +const GetStartedButton = ({ step }: { step: OnboardingStep }) => { + const { CHROME_EXTENSION_URL, MOZILLA_EXTENSION_URL } = useEnvironment(); + const navigate = useNavigate(); const [, setOnboardingViewed] = useLocalStorage( constants.ONBOARDING_VIEWED_KEY ); + const update = useGlobalStore((store) => store.update); + const marketId = useGlobalStore((store) => store.marketId); + const link = marketId ? Links[Routes.MARKET](marketId) : Links[Routes.HOME](); + const openVegaWalletDialog = useVegaWalletDialogStore( + (store) => store.openVegaWalletDialog + ); + const setView = useSidebar((store) => store.setView); + let buttonText = t('Get started'); + let onClickHandle = () => { + openVegaWalletDialog(); + }; + if (step === OnboardingStep.ONBOARDING_WALLET_STEP) { + return ( + + ); + } else if (step === OnboardingStep.ONBOARDING_CONNECT_STEP) { + buttonText = t('Connect'); + } else if (step === OnboardingStep.ONBOARDING_DEPOSIT_STEP) { + buttonText = t('Deposit'); + onClickHandle = () => { + navigate(link); + setView({ type: ViewType.Deposit }); + update({ onBoardingDismissed: true }); + }; + } else if (step === OnboardingStep.ONBOARDING_ORDER_STEP) { + buttonText = t('Dismiss'); + onClickHandle = () => { + navigate(link); + setView({ type: ViewType.Order }); + setOnboardingViewed('true'); + }; + } + + return ( + + {buttonText} + + ); +}; + +export const GetStarted = ({ lead }: Props) => { + const { pubKey } = useVegaWallet(); + const { VEGA_ENV, VEGA_NETWORKS } = useEnvironment(); + const CANONICAL_URL = VEGA_NETWORKS[VEGA_ENV] || 'https://console.vega.xyz'; + const [onBoardingViewed] = useLocalStorage(constants.ONBOARDING_VIEWED_KEY); + const currentStep = useGetOnboardingStep(); const openVegaWalletDialog = useVegaWalletDialogStore( (store) => store.openVegaWalletDialog ); - const onButtonClick = () => { - openVegaWalletDialog(); - setOnboardingViewed('true'); - }; + const getStartedNeeded = + onBoardingViewed !== 'true' && + currentStep && + currentStep < OnboardingStep.ONBOARDING_COMPLETE_STEP; const wrapperClasses = classNames( 'flex flex-col py-4 px-6 gap-4 rounded', @@ -39,27 +106,39 @@ export const GetStarted = ({ lead }: Props) => { { 'mt-8': !lead } ); - if (!pubKey && !isBrowserWalletInstalled()) { + if (getStartedNeeded) { return (
    {lead &&

    {lead}

    }

    {t('Get started')}

    -
      -
    • {t('Get a Vega wallet')}
    • -
    • {t('Connect')}
    • -
    • {t('Deposit funds')}
    • -
    • {t('Open a position')}
    • +
        + OnboardingStep.ONBOARDING_WALLET_STEP} + /> + OnboardingStep.ONBOARDING_CONNECT_STEP || pubKey + )} + /> + OnboardingStep.ONBOARDING_DEPOSIT_STEP} + /> + OnboardingStep.ONBOARDING_ORDER_STEP} + />
    - - {t('Get started')} - +
    {VEGA_ENV === Networks.MAINNET && (

    @@ -84,7 +163,7 @@ export const GetStarted = ({ lead }: Props) => { if (!pubKey) { return (

    -

    +

    You need a{' '} Vega wallet @@ -105,3 +184,34 @@ export const GetStarted = ({ lead }: Props) => { return null; }; + +const Step = ({ + step, + text, + complete, +}: { + step: number; + text: string; + complete: boolean; +}) => { + return ( +

  • +
    + {complete ? : {step}.} +
    +
    {text}
    +
  • + ); +}; + +const Tick = () => { + return ( + + + + ); +}; diff --git a/apps/trading/components/welcome-dialog/use-get-onboarding-step.spec.tsx b/apps/trading/components/welcome-dialog/use-get-onboarding-step.spec.tsx new file mode 100644 index 000000000..3c2e6d30f --- /dev/null +++ b/apps/trading/components/welcome-dialog/use-get-onboarding-step.spec.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react'; +import { + useGetOnboardingStep, + OnboardingStep, +} from './use-get-onboarding-step'; +import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; +import { VegaWalletContext } from '@vegaprotocol/wallet'; +import { useDataProvider } from '@vegaprotocol/data-provider'; +import { depositsProvider } from '@vegaprotocol/deposits'; +import { aggregatedAccountsDataProvider } from '@vegaprotocol/accounts'; +import { ordersWithMarketProvider } from '@vegaprotocol/orders'; +import { positionsDataProvider } from '@vegaprotocol/positions'; + +let mockData: object[] | null = [{ id: 'item-id' }]; +jest.mock('@vegaprotocol/data-provider', () => ({ + ...jest.requireActual('@vegaprotocol/data-provider'), + useDataProvider: jest.fn(() => ({ data: mockData })), +})); + +let mockContext: Partial = { pubKey: 'test-pubkey' }; + +describe('useGetOnboardingStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockData = [{ id: 'item-id' }]; + mockContext = { pubKey: 'test-pubkey' }; + globalThis.window.vega = {} as Vega; + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + it('should return properly ONBOARDING_UNKNOWN_STEP', () => { + mockData = null; + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + expect(result.current).toEqual(OnboardingStep.ONBOARDING_UNKNOWN_STEP); + }); + + it('should return properly ONBOARDING_WALLET_STEP', () => { + // @ts-ignore test only purpose + globalThis.window.vega = undefined; + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + expect(result.current).toEqual(OnboardingStep.ONBOARDING_WALLET_STEP); + }); + + it('should return properly ONBOARDING_CONNECT_STEP', () => { + mockContext = { pubKey: null }; + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + expect(result.current).toEqual(OnboardingStep.ONBOARDING_CONNECT_STEP); + }); + + it('should return properly ONBOARDING_DEPOSIT_STEP', async () => { + (useDataProvider as jest.Mock).mockImplementation((args) => { + if ( + args.dataProvider === depositsProvider || + args.dataProvider === aggregatedAccountsDataProvider + ) { + return { data: [] }; + } + return { data: mockData }; + }); + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + await expect(result.current).toEqual( + OnboardingStep.ONBOARDING_DEPOSIT_STEP + ); + }); + + it('should return properly ONBOARDING_ORDER_STEP', async () => { + (useDataProvider as jest.Mock).mockImplementation((args) => { + if ( + args.dataProvider === ordersWithMarketProvider || + args.dataProvider === positionsDataProvider + ) { + return { data: [] }; + } + return { data: mockData }; + }); + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + await expect(result.current).toEqual(OnboardingStep.ONBOARDING_ORDER_STEP); + }); + + it('should return properly ONBOARDING_COMPLETE_STEP', async () => { + (useDataProvider as jest.Mock).mockImplementation(() => { + return { data: mockData }; + }); + const { result } = renderHook(() => useGetOnboardingStep(), { wrapper }); + await expect(result.current).toEqual( + OnboardingStep.ONBOARDING_COMPLETE_STEP + ); + }); +}); diff --git a/apps/trading/components/welcome-dialog/use-get-onboarding-step.ts b/apps/trading/components/welcome-dialog/use-get-onboarding-step.ts new file mode 100644 index 000000000..741def751 --- /dev/null +++ b/apps/trading/components/welcome-dialog/use-get-onboarding-step.ts @@ -0,0 +1,82 @@ +import { isBrowserWalletInstalled, useVegaWallet } from '@vegaprotocol/wallet'; +import { depositsProvider } from '@vegaprotocol/deposits'; +import { useDataProvider } from '@vegaprotocol/data-provider'; +import { ordersWithMarketProvider } from '@vegaprotocol/orders'; +import * as Types from '@vegaprotocol/types'; +import { aggregatedAccountsDataProvider } from '@vegaprotocol/accounts'; +import { positionsDataProvider } from '@vegaprotocol/positions'; +import { useGlobalStore } from '../../stores'; + +export enum OnboardingStep { + ONBOARDING_UNKNOWN_STEP, + ONBOARDING_WALLET_STEP, + ONBOARDING_CONNECT_STEP, + ONBOARDING_DEPOSIT_STEP, + ONBOARDING_ORDER_STEP, + ONBOARDING_COMPLETE_STEP, +} + +export const useGetOnboardingStep = () => { + const connecting = useGlobalStore((store) => store.eagerConnecting); + const { pubKey = '', pubKeys } = useVegaWallet(); + const { data: depositsData } = useDataProvider({ + dataProvider: depositsProvider, + variables: { partyId: pubKey || '' }, + skip: !pubKey, + }); + const { data: collateralData } = useDataProvider({ + dataProvider: aggregatedAccountsDataProvider, + variables: { partyId: pubKey || '' }, + skip: !pubKey, + }); + const collaterals = Boolean(collateralData?.length); + const deposits = + depositsData?.some( + (item) => item.status === Types.DepositStatus.STATUS_FINALIZED + ) || false; + const { data: ordersData } = useDataProvider({ + dataProvider: ordersWithMarketProvider, + variables: { + partyId: pubKey || '', + pagination: { + first: 1, + }, + }, + skip: !pubKey, + }); + const orders = Boolean(ordersData?.length); + + const partyIds = pubKeys?.map((item) => item.publicKey) || []; + const { data: positionsData } = useDataProvider({ + dataProvider: positionsDataProvider, + variables: { + partyIds, + }, + skip: !partyIds?.length, + }); + const positions = Boolean(positionsData?.length); + + const isLoading = Boolean( + (connecting || pubKey) && + (depositsData === null || + ordersData === null || + collateralData === null || + positionsData === null) + ); + if (isLoading) { + return OnboardingStep.ONBOARDING_UNKNOWN_STEP; + } + if (!isBrowserWalletInstalled()) { + return OnboardingStep.ONBOARDING_WALLET_STEP; + } + if (!pubKey) { + return OnboardingStep.ONBOARDING_CONNECT_STEP; + } + if (!deposits && !collaterals) { + return OnboardingStep.ONBOARDING_DEPOSIT_STEP; + } + if (!orders && !positions) { + return OnboardingStep.ONBOARDING_ORDER_STEP; + } + return OnboardingStep.ONBOARDING_COMPLETE_STEP; +}; diff --git a/apps/trading/components/welcome-dialog/welcome-dialog-content.tsx b/apps/trading/components/welcome-dialog/welcome-dialog-content.tsx index c29637f9f..218db85bb 100644 --- a/apps/trading/components/welcome-dialog/welcome-dialog-content.tsx +++ b/apps/trading/components/welcome-dialog/welcome-dialog-content.tsx @@ -3,21 +3,19 @@ import { GetStarted } from './get-started'; import { TradingButton } from '@vegaprotocol/ui-toolkit'; import { useNavigate } from 'react-router-dom'; import { Links, Routes } from '../../pages/client-router'; -import { useLocalStorage } from '@vegaprotocol/react-helpers'; -import * as constants from '../constants'; import { Networks, useEnvironment } from '@vegaprotocol/environment'; import type { ReactNode } from 'react'; +import { useGlobalStore } from '../../stores'; export const WelcomeDialogContent = () => { const { VEGA_ENV } = useEnvironment(); - const [, setOnboardingViewed] = useLocalStorage( - constants.ONBOARDING_VIEWED_KEY - ); + + const update = useGlobalStore((store) => store.update); const navigate = useNavigate(); const browseMarkets = () => { const link = Links[Routes.MARKETS](); navigate(link); - setOnboardingViewed('true'); + update({ onBoardingDismissed: true }); }; const lead = VEGA_ENV === Networks.MAINNET @@ -57,7 +55,7 @@ export const WelcomeDialogContent = () => { {t('Browse the markets')}
    -
    +
    diff --git a/apps/trading/components/welcome-dialog/welcome-dialog.tsx b/apps/trading/components/welcome-dialog/welcome-dialog.tsx index c1b6670bc..7739156ee 100644 --- a/apps/trading/components/welcome-dialog/welcome-dialog.tsx +++ b/apps/trading/components/welcome-dialog/welcome-dialog.tsx @@ -2,31 +2,38 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { Dialog, Intent } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; -import { useLocalStorage } from '@vegaprotocol/react-helpers'; import { useEnvironment } from '@vegaprotocol/environment'; -import { isBrowserWalletInstalled } from '@vegaprotocol/wallet'; -import * as constants from '../constants'; +import { useLocalStorage } from '@vegaprotocol/react-helpers'; import { WelcomeDialogContent } from './welcome-dialog-content'; -import { getConfig } from '@vegaprotocol/wallet'; import { Links, Routes } from '../../pages/client-router'; import { useGlobalStore } from '../../stores'; +import { + useGetOnboardingStep, + OnboardingStep, +} from './use-get-onboarding-step'; +import * as constants from '../constants'; export const WelcomeDialog = () => { const { VEGA_ENV } = useEnvironment(); - const [onBoardingViewed, setOnboardingViewed] = useLocalStorage( - constants.ONBOARDING_VIEWED_KEY - ); + const [onBoardingViewed] = useLocalStorage(constants.ONBOARDING_VIEWED_KEY); + const update = useGlobalStore((store) => store.update); + const dismissed = useGlobalStore((store) => store.onBoardingDismissed); + const currentStep = useGetOnboardingStep(); + const navigate = useNavigate(); const isOnboardingDialogNeeded = - onBoardingViewed !== 'true' && !isBrowserWalletInstalled() && !getConfig(); + onBoardingViewed !== 'true' && + currentStep && + currentStep < OnboardingStep.ONBOARDING_COMPLETE_STEP && + !dismissed; const marketId = useGlobalStore((store) => store.marketId); const onClose = () => { - setOnboardingViewed('true'); const link = marketId ? Links[Routes.MARKET](marketId) : Links[Routes.HOME](); navigate(link); + update({ onBoardingDismissed: true }); }; const title = ( diff --git a/apps/trading/lib/vega-connectors.ts b/apps/trading/lib/vega-connectors.ts index b1dcf762b..6793dd910 100644 --- a/apps/trading/lib/vega-connectors.ts +++ b/apps/trading/lib/vega-connectors.ts @@ -1,7 +1,10 @@ +import { FLAGS } from '@vegaprotocol/environment'; import { JsonRpcConnector, ViewConnector, InjectedConnector, + SnapConnector, + DEFAULT_SNAP_ID, } from '@vegaprotocol/wallet'; export const jsonRpc = new JsonRpcConnector(); @@ -15,8 +18,13 @@ if (typeof window !== 'undefined') { view = new ViewConnector(); } +export const snap = FLAGS.METAMASK_SNAPS + ? new SnapConnector(DEFAULT_SNAP_ID) + : undefined; + export const Connectors = { injected, jsonRpc, view, + snap, }; diff --git a/apps/trading/pages/_app.page.tsx b/apps/trading/pages/_app.page.tsx index eb74bc937..c3e76a848 100644 --- a/apps/trading/pages/_app.page.tsx +++ b/apps/trading/pages/_app.page.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Head from 'next/head'; import type { AppProps } from 'next/app'; import { t } from '@vegaprotocol/i18n'; @@ -23,7 +23,7 @@ import { useNodeSwitcherStore, } from '@vegaprotocol/environment'; import './styles.css'; -import { usePageTitleStore } from '../stores'; +import { useGlobalStore, usePageTitleStore } from '../stores'; import DialogsContainer from './dialogs-container'; import ToastsManager from './toasts-manager'; import { @@ -170,7 +170,8 @@ const PartyData = () => { const MaybeConnectEagerly = () => { const { VEGA_ENV, SENTRY_DSN } = useEnvironment(); - useVegaEagerConnect(Connectors); + const update = useGlobalStore((store) => store.update); + const eagerConnecting = useVegaEagerConnect(Connectors); const [isTelemetryApproved] = useTelemetryApproval(); useEthereumEagerConnect( isTelemetryApproved ? { dsn: SENTRY_DSN, env: VEGA_ENV } : {} @@ -182,5 +183,8 @@ const MaybeConnectEagerly = () => { if (query && !pubKey) { connect(Connectors['view']); } + useEffect(() => { + update({ eagerConnecting }); + }, [update, eagerConnecting]); return null; }; diff --git a/apps/trading/pages/styles.css b/apps/trading/pages/styles.css index 86e8eb7a3..8072f112c 100644 --- a/apps/trading/pages/styles.css +++ b/apps/trading/pages/styles.css @@ -15,6 +15,10 @@ body, @apply h-full; } +.font-mono { + @apply tracking-tighter; +} + .text-default { @apply text-vega-clight-50 dark:text-vega-cdark-50; } @@ -60,6 +64,10 @@ html.dark { html [data-theme='dark'], html [data-theme='light'] { + /* fonts */ + --pennant-font-family-base: theme(fontFamily.alpha); + --pennant-font-family-monospace: theme(fontFamily.mono); + /* sell candles only use stroke as the candle is solid (without border) */ --pennant-color-sell-stroke: theme(colors.market.red.DEFAULT); @@ -147,7 +155,7 @@ html [data-theme='dark'] { } .vega-ag-grid .ag-header-row { - @apply font-alpha font-normal; + @apply font-normal font-alpha; } /* Light variables */ @@ -209,3 +217,15 @@ html [data-theme='dark'] { box-shadow: inset 0 0 6px rgb(0 0 0 / 30%); background-color: #999; } + +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/apps/trading/stores/global.ts b/apps/trading/stores/global.ts index e5b69fd04..cbd56fd09 100644 --- a/apps/trading/stores/global.ts +++ b/apps/trading/stores/global.ts @@ -4,6 +4,8 @@ import produce from 'immer'; interface GlobalStore { marketId: string | null; + onBoardingDismissed: boolean; + eagerConnecting: boolean; update: (store: Partial>) => void; } @@ -14,6 +16,8 @@ interface PageTitleStore { export const useGlobalStore = create()((set) => ({ marketId: LocalStorage.getItem('marketId') || null, + onBoardingDismissed: false, + eagerConnecting: false, update: (newState) => { set( produce((state: GlobalStore) => { diff --git a/libs/assets/src/lib/Assets.graphql b/libs/assets/src/lib/Assets.graphql index fc221c373..f1149233d 100644 --- a/libs/assets/src/lib/Assets.graphql +++ b/libs/assets/src/lib/Assets.graphql @@ -24,3 +24,26 @@ query Assets { } } } + +fragment PartyAssetFields on Asset { + id + name + symbol + status +} + +query PartyAssets($partyId: ID!) { + party(id: $partyId) { + id + accountsConnection { + edges { + node { + type + asset { + ...PartyAssetFields + } + } + } + } + } +} diff --git a/libs/assets/src/lib/__generated__/Assets.ts b/libs/assets/src/lib/__generated__/Assets.ts index b9c72a4c0..2c67f6742 100644 --- a/libs/assets/src/lib/__generated__/Assets.ts +++ b/libs/assets/src/lib/__generated__/Assets.ts @@ -10,6 +10,15 @@ export type AssetsQueryVariables = Types.Exact<{ [key: string]: never; }>; export type AssetsQuery = { __typename?: 'Query', assetsConnection?: { __typename?: 'AssetsConnection', edges?: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, quantum: string, status: Types.AssetStatus, source: { __typename: 'BuiltinAsset' } | { __typename: 'ERC20', contractAddress: string, lifetimeLimit: string, withdrawThreshold: string } } } | null> | null } | null }; +export type PartyAssetFieldsFragment = { __typename?: 'Asset', id: string, name: string, symbol: string, status: Types.AssetStatus }; + +export type PartyAssetsQueryVariables = Types.Exact<{ + partyId: Types.Scalars['ID']; +}>; + + +export type PartyAssetsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', type: Types.AccountType, asset: { __typename?: 'Asset', id: string, name: string, symbol: string, status: Types.AssetStatus } } } | null> | null } | null } | null }; + export const AssetListFieldsFragmentDoc = gql` fragment AssetListFields on Asset { id @@ -28,6 +37,14 @@ export const AssetListFieldsFragmentDoc = gql` status } `; +export const PartyAssetFieldsFragmentDoc = gql` + fragment PartyAssetFields on Asset { + id + name + symbol + status +} + `; export const AssetsDocument = gql` query Assets { assetsConnection { @@ -65,4 +82,49 @@ export function useAssetsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type AssetsLazyQueryHookResult = ReturnType; -export type AssetsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type AssetsQueryResult = Apollo.QueryResult; +export const PartyAssetsDocument = gql` + query PartyAssets($partyId: ID!) { + party(id: $partyId) { + id + accountsConnection { + edges { + node { + type + asset { + ...PartyAssetFields + } + } + } + } + } +} + ${PartyAssetFieldsFragmentDoc}`; + +/** + * __usePartyAssetsQuery__ + * + * To run a query within a React component, call `usePartyAssetsQuery` and pass it any options that fit your needs. + * When your component renders, `usePartyAssetsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = usePartyAssetsQuery({ + * variables: { + * partyId: // value for 'partyId' + * }, + * }); + */ +export function usePartyAssetsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(PartyAssetsDocument, options); + } +export function usePartyAssetsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(PartyAssetsDocument, options); + } +export type PartyAssetsQueryHookResult = ReturnType; +export type PartyAssetsLazyQueryHookResult = ReturnType; +export type PartyAssetsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/assets/src/lib/party-assets.mock.ts b/libs/assets/src/lib/party-assets.mock.ts new file mode 100644 index 000000000..5ace1ed2f --- /dev/null +++ b/libs/assets/src/lib/party-assets.mock.ts @@ -0,0 +1,47 @@ +import merge from 'lodash/merge'; +import type { PartyAssetsQuery } from './__generated__/Assets'; +import * as Types from '@vegaprotocol/types'; +import type { PartialDeep } from 'type-fest'; + +export const partyAssetsQuery = ( + override?: PartialDeep +): PartyAssetsQuery => { + const defaultAssets: PartyAssetsQuery = { + party: { + __typename: 'Party', + id: 'partyId', + accountsConnection: { + edges: partyAccountFields.map((node) => ({ + __typename: 'AccountEdge', + node, + })), + }, + }, + }; + return merge(defaultAssets, override); +}; + +const partyAccountFields = [ + { + __typename: 'AccountBalance', + type: Types.AccountType.ACCOUNT_TYPE_MARGIN, + asset: { + __typename: 'Asset', + id: 'asset-id', + symbol: 'tEURO', + name: 'Euro', + status: Types.AssetStatus.STATUS_ENABLED, + }, + }, + { + __typename: 'AccountBalance', + type: Types.AccountType.ACCOUNT_TYPE_MARGIN, + asset: { + __typename: 'Asset', + id: 'asset-id-2', + symbol: 'tDAI', + name: 'DAI', + status: Types.AssetStatus.STATUS_ENABLED, + }, + }, +] as const; diff --git a/libs/cypress/mock.ts b/libs/cypress/mock.ts index ababae6d4..598b05b3f 100644 --- a/libs/cypress/mock.ts +++ b/libs/cypress/mock.ts @@ -2,6 +2,7 @@ export * from '../accounts/src/lib/accounts.mock'; export * from '../assets/src/lib/asset.mock'; export * from '../assets/src/lib/assets.mock'; +export * from '../assets/src/lib/party-assets.mock'; export * from '../candles-chart/src/lib/candles.mock'; export * from '../candles-chart/src/lib/chart.mock'; export * from '../deal-ticket/src/hooks/estimate-order.mock'; @@ -10,7 +11,6 @@ export * from '../environment/src/utils/node.mock'; export * from '../environment/src/components/node-guard/node-guard.mock'; export * from '../fills/src/lib/fills.mock'; export * from '../proposals/src/lib/proposals-data-provider/proposals.mock'; -export * from '../ledger/src/lib/ledger-entries.mock'; export * from '../market-depth/src/lib/market-depth.mock'; export * from '../markets/src/lib/components/market-info/market-info.mock'; export * from '../markets/src/lib/market-candles.mock'; diff --git a/libs/cypress/src/lib/capsule/propose-market.ts b/libs/cypress/src/lib/capsule/propose-market.ts index 09a7a54cb..83da99758 100644 --- a/libs/cypress/src/lib/capsule/propose-market.ts +++ b/libs/cypress/src/lib/capsule/propose-market.ts @@ -153,8 +153,10 @@ export function waitForProposal(id: string): Promise<{ id: string }> { try { const res = await getProposal(id); if ( - res.proposal !== null && - res.proposal.state === Schema.ProposalState.STATE_OPEN + (res.proposal !== null && + res.proposal.state === Schema.ProposalState.STATE_OPEN) || + res.proposal.state === + Schema.ProposalState.STATE_WAITING_FOR_NODE_VOTE ) { clearInterval(interval); resolve(res.proposal); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx deleted file mode 100644 index c1f949da5..000000000 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { Control } from 'react-hook-form'; -import type { Market, StaticMarketData } from '@vegaprotocol/markets'; -import { DealTicketMarketAmount } from './deal-ticket-market-amount'; -import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; -import * as Schema from '@vegaprotocol/types'; -import type { OrderFormValues } from '../../hooks/use-form-values'; - -export interface DealTicketAmountProps { - control: Control; - type: Schema.OrderType; - marketData: StaticMarketData; - marketPrice?: string; - market: Market; - sizeError?: string; - priceError?: string; -} - -export const DealTicketAmount = ({ - type, - marketData, - marketPrice, - ...props -}: DealTicketAmountProps) => { - switch (type) { - case Schema.OrderType.TYPE_MARKET: - return ( - - ); - case Schema.OrderType.TYPE_LIMIT: - return ; - default: { - throw new Error('Invalid ticket type ' + type); - } - } -}; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx deleted file mode 100644 index 39070688c..000000000 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { t } from '@vegaprotocol/i18n'; -import { Side } from '@vegaprotocol/types'; -import classNames from 'classnames'; - -interface Props { - side: Side; - label?: string; -} - -export const DealTicketButton = ({ side, label }: Props) => { - const buttonClasses = classNames( - 'px-10 py-2 uppercase rounded-md text-white w-full', - { - 'bg-market-red': side === Side.SIDE_SELL, - 'bg-market-green-550': side === Side.SIDE_BUY, - } - ); - return ( -
    - -
    - ); -}; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx index 7df3041f3..182ab55fa 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx @@ -1,7 +1,4 @@ import { useCallback, useState } from 'react'; -import { Tooltip } from '@vegaprotocol/ui-toolkit'; -import classnames from 'classnames'; -import type { ReactNode } from 'react'; import { t } from '@vegaprotocol/i18n'; import { FeesBreakdown } from '@vegaprotocol/markets'; import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; @@ -16,7 +13,6 @@ import { marketMarginDataProvider } from '@vegaprotocol/accounts'; import { useDataProvider } from '@vegaprotocol/data-provider'; import { - NOTIONAL_SIZE_TOOLTIP_TEXT, MARGIN_DIFF_TOOLTIP_TEXT, DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT, TOTAL_MARGIN_AVAILABLE, @@ -25,114 +21,54 @@ import { MARGIN_ACCOUNT_TOOLTIP_TEXT, } from '../../constants'; import { useEstimateFees } from '../../hooks'; +import { KeyValue } from './key-value'; const emptyValue = '-'; -export interface DealTicketFeeDetailPros { - label: string; - value?: string | null | undefined; - symbol: string; - indent?: boolean | undefined; - labelDescription?: ReactNode; - formattedValue?: string; - onClick?: () => void; -} - -export const DealTicketFeeDetail = ({ - label, - value, - labelDescription, - symbol, - indent, - onClick, - formattedValue, -}: DealTicketFeeDetailPros) => { - const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`; - const valueElement = onClick ? ( - - ) : ( -
    {displayValue}
    - ); - return ( -
    - -
    {label}
    -
    - - {valueElement} - -
    - ); -}; - 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 ( - <> - - - - {t( - `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.` - )} - - - - } - symbol={assetSymbol} - /> - + + + {t( + `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.` + )} + + + + } + symbol={assetSymbol} + /> ); }; @@ -209,7 +145,7 @@ export const DealTicketMarginDetails = ({ BigInt(marginAccountBalance); deductionFromCollateral = ( - ); projectedMargin = ( - - - {deductionFromCollateral} - {projectedMargin} - ; - -export const DealTicketLimitAmount = ({ - control, - market, - sizeError, - priceError, -}: DealTicketLimitAmountProps) => { - const priceStep = toDecimal(market?.decimalPlaces); - const sizeStep = toDecimal(market?.positionDecimalPlaces); - const quoteName = market.tradableInstrument.instrument.product.quoteName; - - const renderError = () => { - if (sizeError) { - return ( - - {sizeError} - - ); - } - - if (priceError) { - return ( - - {priceError} - - ); - } - - return null; - }; - - return ( -
    -
    -
    - - ( - e.currentTarget.blur()} - hasError={!!fieldState.error} - {...field} - /> - )} - /> - -
    -
    @
    -
    - - ( - e.currentTarget.blur()} - hasError={!!fieldState.error} - {...field} - /> - )} - /> - -
    -
    - {renderError()} -
    - ); -}; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx deleted file mode 100644 index 82e003364..000000000 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { - addDecimalsFormatNumber, - toDecimal, - validateAmount, -} from '@vegaprotocol/utils'; -import { t } from '@vegaprotocol/i18n'; -import { - TradingInput, - TradingInputError, - Tooltip, -} from '@vegaprotocol/ui-toolkit'; -import { isMarketInAuction } from '@vegaprotocol/markets'; -import type { DealTicketAmountProps } from './deal-ticket-amount'; -import { Controller } from 'react-hook-form'; -import classNames from 'classnames'; - -export type DealTicketMarketAmountProps = Omit; - -export const DealTicketMarketAmount = ({ - control, - market, - marketData, - marketPrice, - sizeError, -}: DealTicketMarketAmountProps) => { - const quoteName = market.tradableInstrument.instrument.product.quoteName; - const sizeStep = toDecimal(market?.positionDecimalPlaces); - const price = marketPrice; - - const priceFormatted = price - ? addDecimalsFormatNumber(price, market.decimalPlaces) - : undefined; - - const inAuction = isMarketInAuction(marketData.marketTradingMode); - - return ( -
    -
    -
    -
    {t('Size')}
    - ( - e.currentTarget.blur()} - data-testid="order-size" - hasError={!!fieldState.error} - {...field} - /> - )} - /> -
    -
    @
    -
    - {inAuction && ( - -
    {t(`Indicative price`)}
    -
    - )} -
    - {priceFormatted && quoteName ? ( - <> - ~{priceFormatted} {quoteName} - - ) : ( - '-' - )} -
    -
    -
    - {sizeError && ( - - {sizeError} - - )} -
    - ); -}; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx index 1b69cac2b..ede1043bc 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx @@ -32,7 +32,7 @@ export const DealTicketSizeIceberg = ({ const renderPeakSizeError = () => { if (peakSizeError) { return ( - + {peakSizeError} ); @@ -44,7 +44,7 @@ export const DealTicketSizeIceberg = ({ const renderMinimumSizeError = () => { if (minimumVisibleSizeError) { return ( - + {minimumVisibleSizeError} ); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx index 4f9c57931..ce1762733 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { VegaWalletContext } from '@vegaprotocol/wallet'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, 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'; @@ -12,6 +12,7 @@ import { useDealTicketFormValues, } from '../../hooks/use-form-values'; import type { FeatureFlags } from '@vegaprotocol/environment'; +import { formatForInput } from '@vegaprotocol/utils'; jest.mock('zustand'); jest.mock('./deal-ticket-fee-details', () => ({ @@ -57,7 +58,7 @@ const orderSideBuy = 'order-side-SIDE_BUY'; const orderSideSell = 'order-side-SIDE_SELL'; const triggerDirectionRisesAbove = 'triggerDirection-risesAbove'; -// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; +const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; const expiryStrategySubmit = 'expiryStrategy-submit'; const expiryStrategyCancel = 'expiryStrategy-cancel'; @@ -65,6 +66,7 @@ const expiryStrategyCancel = 'expiryStrategy-cancel'; const triggerTypePrice = 'triggerType-price'; const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset'; +const oco = 'oco'; const expire = 'expire'; const datePicker = 'date-picker-field'; const timeInForce = 'order-tif'; @@ -72,9 +74,12 @@ 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 triggerPriceWarningMessage = 'stop-order-warning-message-trigger-price'; const triggerTrailingPercentOffsetErrorMessage = 'stop-order-error-message-trigger-trailing-percent-offset'; +const ocoPostfix = (id: string, postfix = true) => (postfix ? `${id}-oco` : id); + describe('StopOrder', () => { beforeEach(() => { localStorage.clear(); @@ -106,6 +111,7 @@ describe('StopOrder', () => { 'checked' ); expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked'); + expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked'); await userEvent.click(screen.getByTestId(expire)); await waitFor(() => { expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( @@ -114,12 +120,30 @@ describe('StopOrder', () => { }); }); - it('should display trigger price as price for market type order', async () => { + it('calculate notional for market limit', async () => { + render(generateJsx()); + await userEvent.type(screen.getByTestId(sizeInput), '10'); + await userEvent.type(screen.getByTestId(priceInput), '10'); + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional100.00 BTC' + ); + }); + + it('calculates notional for limit 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'); + await userEvent.type(screen.getByTestId(sizeInput), '10'); + // price trigger is selected but it's empty, calculate base on size and marketPrice prop + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional20.00 BTC' + ); + + await userEvent.type(screen.getByTestId(triggerPriceInput), '3'); + // calculate base on size and price trigger + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional30.00 BTC' + ); }); it('should use local storage state for initial values', async () => { @@ -132,6 +156,11 @@ describe('StopOrder', () => { expire: true, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS, expiresAt: '2023-07-27T16:43:27.000', + oco: true, + ocoType: Schema.OrderType.TYPE_LIMIT, + ocoSize: '0.2', + ocoPrice: '300.23', + ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, }; useDealTicketFormValues.setState({ @@ -150,10 +179,22 @@ describe('StopOrder', () => { expect(screen.getByTestId(sizeInput)).toHaveDisplayValue( values.size as string ); - expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce); + expect(screen.getByTestId(timeInForce)).toHaveValue(values.timeInForce); expect(screen.getByTestId(priceInput)).toHaveDisplayValue( values.price as string ); + + expect(screen.getByTestId(ocoPostfix(sizeInput))).toHaveDisplayValue( + values.ocoSize as string + ); + expect(screen.getByTestId(ocoPostfix(timeInForce))).toHaveValue( + values.ocoTimeInForce + ); + expect(screen.getByTestId(ocoPostfix(priceInput))).toHaveDisplayValue( + values.ocoPrice as string + ); + expect(screen.getByTestId('ocoTypeLimit').dataset.state).toEqual('checked'); + expect(screen.getByTestId(expire).dataset.state).toEqual('checked'); expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual( 'checked' @@ -161,6 +202,9 @@ describe('StopOrder', () => { expect(screen.getByTestId(datePicker)).toHaveDisplayValue( values.expiresAt as string ); + + await userEvent.click(screen.getByTestId(orderTypeMarket)); + expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked'); }); it('does not submit if no wallet connected', async () => { @@ -181,138 +225,239 @@ describe('StopOrder', () => { expect(submit).toBeCalled(); }); - it('validates size field', async () => { + it.each([ + { fieldName: 'size', ocoValue: false }, + { fieldName: 'ocoSize', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); - + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } await userEvent.click(screen.getByTestId(submitButton)); - + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); // default value should be invalid - expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + expect(getByTestId(sizeErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(sizeInput), '0.01'); - expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(sizeInput), '0.01'); + expect(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(); + await userEvent.clear(getByTestId(sizeInput)); + await userEvent.type(getByTestId(sizeInput), '0.1'); + expect(queryByTestId(sizeErrorMessage)).toBeNull(); }); - it('validates price field', async () => { + it.each([ + { fieldName: 'price', ocoValue: false }, + { fieldName: 'ocoPrice', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); - + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } 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(); + + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); + + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(priceInput), '0.001'); + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); // switch to market order type error should disappear await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeMarket)); await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + expect(queryByTestId(priceErrorMessage)).toBeNull(); // switch back to limit type await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeLimit)); await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(priceInput), '0.001'); - expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(priceInput), '0.001'); + expect(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(); + await userEvent.clear(getByTestId(priceInput)); + await userEvent.type(getByTestId(priceInput), '0.01'); + expect(queryByTestId(priceErrorMessage)).toBeNull(); }); - it('validates trigger price field', async () => { + it.each([ + { fieldName: 'triggerPrice', ocoValue: false }, + { fieldName: 'ocoTriggerPrice', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow)); + } await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); + expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); // switch to trailing percentage offset trigger type - await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); - expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); + await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset)); + expect(queryByTestId(triggerPriceErrorMessage)).toBeNull(); // switch back to price trigger type - await userEvent.click(screen.getByTestId(triggerTypePrice)); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + await userEvent.click(getByTestId(triggerTypePrice)); + expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001'); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(triggerPriceInput), '0.001'); + expect(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(); + // clear and fill using value causing immediate trigger + await userEvent.clear(getByTestId(triggerPriceInput)); + await userEvent.type(getByTestId(triggerPriceInput), '0.01'); + expect(queryByTestId(triggerPriceErrorMessage)).toBeNull(); + expect(queryByTestId(triggerPriceWarningMessage)).toBeInTheDocument(); + + // change to correct value + await userEvent.type(getByTestId(triggerPriceInput), '2'); + expect(queryByTestId(triggerPriceWarningMessage)).toBeNull(); }); - it('validates trigger trailing percentage offset field', async () => { + it.each([ + { fieldName: 'trailingPercentageOffset', ocoValue: false }, + { fieldName: 'ocoTrailingPercentageOffset', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } + await userEvent.click(screen.getByTestId(submitButton)); + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); // should not show error with default form values - await userEvent.click(screen.getByTestId(submitButton)); - expect( - screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) - ).toBeNull(); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); // switch to trailing percentage offset trigger type - await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); + await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset)); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + getByTestId(triggerTrailingPercentOffsetErrorMessage) ).toBeInTheDocument(); // to small value should be invalid await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '0.09' ); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + 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(); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); + await userEvent.type(getByTestId(triggerTrailingPercentOffsetInput), '0.1'); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); // to big value should be invalid - await userEvent.clear( - screen.getByTestId(triggerTrailingPercentOffsetInput) - ); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '99.91' ); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + getByTestId(triggerTrailingPercentOffsetErrorMessage) ).toBeInTheDocument(); // clear and fill using valid value - await userEvent.clear( - screen.getByTestId(triggerTrailingPercentOffsetInput) - ); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '99.9' ); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); + }); + + it('sync oco trigger', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(oco)); expect( - screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) - ).toBeNull(); + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('checked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('checked'); + await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow)); + expect( + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('unchecked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('unchecked'); + await userEvent.click( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)) + ); + expect( + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('checked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('checked'); + }); + + it('disables submit expiry strategy when OCO selected', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(expire)); + await userEvent.click(screen.getByTestId(expiryStrategySubmit)); + await userEvent.click(screen.getByTestId(oco)); + expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( + 'unchecked' + ); + expect(screen.getByTestId(expiryStrategySubmit)).toBeDisabled(); + await userEvent.click(screen.getByTestId(oco)); + await userEvent.click(screen.getByTestId(expiryStrategySubmit)); + expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId(expiryStrategySubmit)).not.toBeDisabled(); + }); + + it('sets expiry time/date to now if expiry is changed to checked', async () => { + const now = Math.round(Date.now() / 1000) * 1000; + render(generateJsx()); + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now); + await userEvent.click(screen.getByTestId(expire)); + + // expiry time/date was empty it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); + + // set to the value in the past (now - 1s) + fireEvent.change(screen.getByTestId(datePicker), { + target: { value: formatForInput(new Date(now - 1000)) }, + }); + expect( + new Date( + screen.getByTestId(datePicker).value + ).getTime() + 1000 + ).toEqual(now); + + // switch expiry off and on + await userEvent.click(screen.getByTestId(expire)); + await userEvent.click(screen.getByTestId(expire)); + // expiry time/date was in the past it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); }); }); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx index dbe61bdf3..cf9368dad 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx @@ -1,25 +1,34 @@ import { useRef, useCallback, useEffect } from 'react'; import { useVegaWallet } from '@vegaprotocol/wallet'; -import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; +import type { + OrderSubmissionBody, + StopOrdersSubmission, +} from '@vegaprotocol/wallet'; import { - formatNumber, + formatForInput, + formatValue, removeDecimal, toDecimal, validateAmount, } from '@vegaprotocol/utils'; +import type { Control, UseFormWatch } from 'react-hook-form'; import { useForm, Controller, useController } from 'react-hook-form'; import * as Schema from '@vegaprotocol/types'; import { - TradingRadio, - TradingRadioGroup, - TradingInput, - TradingCheckbox, - TradingFormGroup, - TradingInputError, - TradingSelect, + TradingRadio as Radio, + TradingRadioGroup as RadioGroup, + TradingInput as Input, + TradingCheckbox as Checkbox, + TradingFormGroup as FormGroup, + TradingInputError as InputError, + TradingSelect as Select, Tooltip, + TradingButton as Button, + Pill, + Intent, } from '@vegaprotocol/ui-toolkit'; -import { getDerivedPrice, type Market } from '@vegaprotocol/markets'; +import { getDerivedPrice } from '@vegaprotocol/markets'; +import type { Market } from '@vegaprotocol/markets'; import { t } from '@vegaprotocol/i18n'; import { ExpirySelector } from './expiry-selector'; import { SideSelector } from './side-selector'; @@ -29,19 +38,21 @@ import { REDUCE_ONLY_TOOLTIP, stopSubmit, getNotionalSize, + getAssetUnit, } from './deal-ticket'; import { TypeToggle } from './type-selector'; import { useDealTicketFormValues, DealTicketType, - type StopOrderFormValues, dealTicketTypeToOrderType, isStopOrderType, } from '../../hooks/use-form-values'; +import type { StopOrderFormValues } from '../../hooks/use-form-values'; import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission'; -import { DealTicketButton } from './deal-ticket-button'; import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { validateExpiration } from '../../utils'; +import { NOTIONAL_SIZE_TOOLTIP_TEXT } from '../../constants'; +import { KeyValue } from './key-value'; export interface StopOrderProps { market: Market; @@ -49,6 +60,8 @@ export interface StopOrderProps { submit: (order: StopOrdersSubmission) => void; } +const trailingPercentOffsetStep = '0.1'; + const getDefaultValues = ( type: Schema.OrderType, storedValues?: Partial @@ -62,9 +75,677 @@ const getDefaultValues = ( expire: false, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT, size: '0', + oco: false, + ocoType: type, + ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + ocoTriggerType: 'price', + ocoSize: '0', ...storedValues, }); +const Trigger = ({ + control, + watch, + priceStep, + quoteName, + oco, + marketPrice, + decimalPlaces, +}: { + control: Control; + watch: UseFormWatch; + priceStep: string; + quoteName: string; + oco?: boolean; + marketPrice?: string | null; + decimalPlaces: number; +}) => { + const triggerType = watch(oco ? 'ocoTriggerType' : 'triggerType'); + const triggerDirection = watch('triggerDirection'); + const isPriceTrigger = triggerType === 'price'; + return ( + + { + const { value, onChange } = field; + return ( + + + + + ); + }} + /> + {isPriceTrigger && ( +
    + { + const { value, ...props } = field; + let triggerWarning = false; + + if (marketPrice && value) { + const condition = + (!oco && + triggerDirection === + Schema.StopOrderTriggerDirection + .TRIGGER_DIRECTION_RISES_ABOVE) || + (oco && + triggerDirection === + Schema.StopOrderTriggerDirection + .TRIGGER_DIRECTION_FALLS_BELOW) + ? '>' + : '<'; + const diff = + BigInt(marketPrice) - + BigInt(removeDecimal(value, decimalPlaces)); + if ( + (condition === '>' && diff > 0) || + (condition === '<' && diff < 0) + ) { + triggerWarning = true; + } + } + return ( + <> +
    + {quoteName}} + value={value || ''} + hasError={!!fieldState.error} + {...props} + /> +
    + {fieldState.error && ( + + {fieldState.error.message} + + )} + {!fieldState.error && triggerWarning && ( + + {t('Stop order will be triggered immediately')} + + )} + + ); + }} + /> +
    + )} + {!isPriceTrigger && ( +
    + { + const { value, ...props } = field; + return ( + <> +
    + %} + data-testid={`triggerTrailingPercentOffset${ + oco ? '-oco' : '' + }`} + value={value || ''} + hasError={!!fieldState.error} + {...props} + /> +
    + {fieldState.error && ( + + {fieldState.error.message} + + )} + + ); + }} + /> +
    + )} + { + const { onChange, value } = field; + return ( + + + + + ); + }} + /> +
    + ); +}; + +const Size = ({ + control, + sizeStep, + oco, + isLimitType, + assetUnit, +}: { + control: Control; + sizeStep: string; + oco?: boolean; + isLimitType: boolean; + assetUnit?: string; +}) => { + return ( + { + const { value, ...props } = field; + const id = `order-size${oco ? '-oco' : ''}`; + return ( +
    + + e.currentTarget.blur()} + appendElement={assetUnit && {assetUnit}} + data-testid={id} + value={value || ''} + hasError={!!fieldState.error} + {...props} + /> + + {fieldState.error && ( + + {fieldState.error.message} + + )} +
    + ); + }} + /> + ); +}; + +const Price = ({ + control, + watch, + priceStep, + quoteName, + oco, +}: { + control: Control; + watch: UseFormWatch; + priceStep: string; + quoteName: string; + oco?: boolean; +}) => { + if (watch(oco ? 'ocoType' : 'type') === Schema.OrderType.TYPE_MARKET) { + return null; + } + return ( + { + const { value, ...props } = field; + const id = `order-price${oco ? '-oco' : ''}`; + return ( +
    + + e.currentTarget.blur()} + value={value || ''} + hasError={!!fieldState.error} + appendElement={{quoteName}} + {...props} + /> + + {fieldState.error && ( + + {fieldState.error.message} + + )} +
    + ); + }} + /> + ); +}; + +const TimeInForce = ({ + control, + oco, +}: { + control: Control; + oco?: boolean; +}) => ( + { + const id = `order-tif${oco ? '-oco' : ''}`; + return ( +
    + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} +
    + ); + }} + /> +); + +const ReduceOnly = () => ( + {t(REDUCE_ONLY_TOOLTIP)}
    }> + <>{t('Reduce only')} + + } + /> +); + +const NotionalAndFees = ({ + market, + marketPrice, + side, + size, + price, + timeInForce, + triggerPrice, + triggerType, + type, +}: Pick< + OrderSubmissionBody['orderSubmission'], + 'side' | 'size' | 'timeInForce' | 'type' | 'price' +> & + Pick & + Pick) => { + const { quoteName, settlementAsset: asset } = + market.tradableInstrument.instrument.product; + const isPriceTrigger = triggerType === 'price'; + const derivedPrice = getDerivedPrice( + { + type, + price, + }, + type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice + ? removeDecimal(triggerPrice, market.decimalPlaces) + : marketPrice || '0' + ); + + const notionalSize = getNotionalSize( + derivedPrice, + size, + market.decimalPlaces, + market.positionDecimalPlaces + ); + return ( +
    + + +
    + ); +}; + +const formatSizeAtPrice = ({ + assetUnit, + decimalPlaces, + positionDecimalPlaces, + price, + quoteName, + side, + size, + type, +}: Pick & { + assetUnit?: string; + decimalPlaces: number; + positionDecimalPlaces: number; + quoteName: string; +}) => + `${formatValue( + removeDecimal(size, positionDecimalPlaces), + positionDecimalPlaces + )} ${assetUnit} @ ${ + type === Schema.OrderType.TYPE_MARKET + ? 'market' + : `${formatValue( + removeDecimal(price || '0', decimalPlaces), + decimalPlaces + )} ${quoteName}` + }`; +const formatTrigger = ({ + decimalPlaces, + triggerDirection, + triggerPrice, + triggerTrailingPercentOffset, + triggerType, + quoteName, +}: Pick< + StopOrderFormValues, + | 'triggerDirection' + | 'triggerType' + | 'triggerPrice' + | 'triggerTrailingPercentOffset' +> & { + decimalPlaces: number; + quoteName: string; +}) => + `${ + triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE + ? t('above') + : t('below') + } ${ + triggerType === 'price' + ? `${formatValue( + removeDecimal(triggerPrice || '', decimalPlaces), + decimalPlaces + )} ${quoteName}` + : `${(Number(triggerTrailingPercentOffset) || 0).toFixed(1)}% ${t( + 'trailing' + )}` + }`; + +const SubmitButton = ({ + assetUnit, + market, + oco, + ocoPrice, + ocoSize, + ocoTriggerPrice, + ocoTriggerTrailingPercentOffset, + ocoTriggerType, + ocoType, + price, + side, + size, + triggerDirection, + triggerPrice, + triggerTrailingPercentOffset, + triggerType, + type, +}: Pick< + StopOrderFormValues, + | 'oco' + | 'ocoPrice' + | 'ocoSize' + | 'ocoTriggerPrice' + | 'ocoTriggerTrailingPercentOffset' + | 'ocoTriggerType' + | 'ocoType' + | 'price' + | 'side' + | 'size' + | 'triggerDirection' + | 'triggerPrice' + | 'triggerTrailingPercentOffset' + | 'triggerType' + | 'type' +> & + Pick & { assetUnit?: string }) => { + const { quoteName } = market.tradableInstrument.instrument.product; + const risesAbove = + triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE; + const subLabel = oco ? ( + <> + {formatSizeAtPrice({ + assetUnit, + decimalPlaces: market.decimalPlaces, + positionDecimalPlaces: market.positionDecimalPlaces, + price: risesAbove ? price : ocoPrice, + quoteName, + side, + size: risesAbove ? size : ocoSize, + type, + })}{' '} + {formatTrigger({ + decimalPlaces: market.decimalPlaces, + quoteName, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + triggerPrice: risesAbove ? triggerPrice : ocoTriggerPrice, + triggerTrailingPercentOffset: risesAbove + ? triggerTrailingPercentOffset + : ocoTriggerTrailingPercentOffset, + triggerType: risesAbove ? triggerType : ocoTriggerType, + })} +
    + {formatSizeAtPrice({ + assetUnit, + decimalPlaces: market.decimalPlaces, + positionDecimalPlaces: market.positionDecimalPlaces, + price: !risesAbove ? price : ocoPrice, + quoteName, + side, + size: !risesAbove ? size : ocoSize, + type: ocoType, + })}{' '} + {formatTrigger({ + decimalPlaces: market.decimalPlaces, + quoteName, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW, + triggerPrice: !risesAbove ? triggerPrice : ocoTriggerPrice, + triggerTrailingPercentOffset: !risesAbove + ? triggerTrailingPercentOffset + : ocoTriggerTrailingPercentOffset, + triggerType: !risesAbove ? triggerType : ocoTriggerType, + })} + + ) : ( + <> + {formatSizeAtPrice({ + assetUnit, + decimalPlaces: market.decimalPlaces, + positionDecimalPlaces: market.positionDecimalPlaces, + price, + quoteName, + side, + size, + type, + })} +
    + {t('Trigger')}{' '} + {formatTrigger({ + decimalPlaces: market.decimalPlaces, + quoteName, + triggerDirection, + triggerPrice, + triggerTrailingPercentOffset, + triggerType, + })} + + ); + return ( + + ); +}; + export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { const { pubKey, isReadOnly } = useVegaWallet(); const setType = useDealTicketFormValues((state) => state.setType); @@ -100,48 +781,40 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { }, [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 rawPrice = watch('price'); - const rawSize = watch('size'); - - useEffect(() => { - const size = storedFormValues?.[dealTicketType]?.size; - if (size && rawSize !== size) { - setValue('size', size); - } - }, [storedFormValues, dealTicketType, rawSize, setValue]); - - useEffect(() => { - const price = storedFormValues?.[dealTicketType]?.price; - if (price && rawPrice !== price) { - setValue('price', price); - } - }, [storedFormValues, dealTicketType, rawPrice, setValue]); - - 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 = getNotionalSize( - price, - size, - market.decimalPlaces, - market.positionDecimalPlaces + const expiresAt = watch('expiresAt'); + const oco = watch('oco'); + const ocoPrice = watch('ocoPrice'); + const ocoSize = watch('ocoSize'); + const ocoTimeInForce = watch('ocoTimeInForce'); + const ocoTriggerPrice = watch('ocoTriggerPrice'); + const ocoTriggerTrailingPercentOffset = watch( + 'ocoTriggerTrailingPercentOffset' ); + const ocoTriggerType = watch('ocoTriggerType'); + const ocoType = watch('ocoType'); + const price = watch('price'); + const side = watch('side'); + const size = watch('size'); + const timeInForce = watch('timeInForce'); + const triggerDirection = watch('triggerDirection'); + const triggerPrice = watch('triggerPrice'); + const triggerTrailingPercentOffset = watch('triggerTrailingPercentOffset'); + const triggerType = watch('triggerType'); + + useEffect(() => { + const storedSize = storedFormValues?.[dealTicketType]?.size; + if (storedSize && size !== storedSize) { + setValue('size', storedSize); + } + }, [storedFormValues, dealTicketType, size, setValue]); + + useEffect(() => { + const storedPrice = storedFormValues?.[dealTicketType]?.price; + if (storedPrice && price !== storedPrice) { + setValue('price', storedPrice); + } + }, [storedFormValues, dealTicketType, price, setValue]); useEffect(() => { const subscription = watch((value, { name, type }) => { @@ -150,23 +823,23 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { return () => subscription.unsubscribe(); }, [watch, market.id, updateStoredFormValues]); - const { quoteName, settlementAsset: asset } = - market.tradableInstrument.instrument.product; + const { quoteName } = market.tradableInstrument.instrument.product; + const assetUnit = getAssetUnit( + market.tradableInstrument.instrument.metadata.tags + ); const sizeStep = toDecimal(market?.positionDecimalPlaces); const priceStep = toDecimal(market?.decimalPlaces); - const trailingPercentOffsetStep = '0.1'; - - const priceFormatted = - isPriceTrigger && triggerPrice - ? formatNumber(triggerPrice, market.decimalPlaces) - : undefined; useController({ name: 'type', control, }); + const normalizedPrice = price && removeDecimal(price, market.decimalPlaces); + const normalizedSize = + size && removeDecimal(size, market.positionDecimalPlaces); + return (
    { }} /> {errors.type && ( - + {errors.type.message} - + )} { )} /> - - { - const { onChange, value } = field; - return ( - - - - - ); - }} - /> - {isPriceTrigger && ( -
    - { - const { value, ...props } = field; - return ( -
    - -
    - ); - }} - /> - {errors.triggerPrice && ( - - {errors.triggerPrice.message} - - )} -
    - )} - {!isPriceTrigger && ( -
    - { - const { value, ...props } = field; - return ( -
    - -
    - ); - }} - /> - {errors.triggerTrailingPercentOffset && ( - - {errors.triggerTrailingPercentOffset.message} - - )} -
    - )} - { - const { onChange, value } = field; - return ( - - - - - ); - }} - /> -
    -
    -
    - - { - const { value, ...props } = field; - return ( - e.currentTarget.blur()} - data-testid="order-size" - value={value || ''} - hasError={!!fieldState.error} - {...props} - /> - ); - }} - /> - -
    @
    -
    - {type === Schema.OrderType.TYPE_LIMIT ? ( - - { - const { value, ...props } = field; - return ( - e.currentTarget.blur()} - value={value || ''} - hasError={!!fieldState.error} - {...props} - /> - ); - }} - /> - - ) : ( -
    - {priceFormatted && quoteName - ? `~${priceFormatted} ${quoteName}` - : '-'} -
    - )} -
    -
    - {errors.size && ( - - {errors.size.message} - - )} - - {!errors.size && - errors.price && - type === Schema.OrderType.TYPE_LIMIT && ( - - {errors.price.message} - - )} + +
    + + + + +
    +
    -
    - - +
    + { + const { onChange, value } = field; + return ( + { + onChange(state); + setValue( + 'expiryStrategy', + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS + ); + }} + checked={value} + name="oco" + label={ + {t('One cancels another')}} + > + <>{t('OCO')} + + } + /> + ); + }} + /> +
    + {oco && ( + <> + + { + const { onChange, value } = field; + return ( + + + + + ); + }} + /> + + ( - - - - - )} + watch={watch} + priceStep={priceStep} + quoteName={quoteName} + marketPrice={marketPrice} + decimalPlaces={market.decimalPlaces} + oco /> -
    - {errors.timeInForce && ( - - {errors.timeInForce.message} - - )} -
    -
    +
    + + + + +
    + +
    + + )} +
    { const { onChange: onCheckedChange, value } = field; return ( - { + const now = Date.now(); + if ( + value && + (!expiresAt || new Date(expiresAt).getTime() < now) + ) { + setValue('expiresAt', formatForInput(new Date(now)), { + shouldValidate: true, + }); + } + onCheckedChange(value); + }} checked={value} name="expire" label={t('Expire')} @@ -503,54 +1037,47 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { ); }} /> - {t(REDUCE_ONLY_TOOLTIP)}}> - <>{t('Reduce only')} - - } - />
    {expire && ( <> - + { + const { onChange, value } = field; return ( - - + - - + ); }} /> - -
    + +
    { @@ -568,19 +1095,24 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { )} - - ); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx index 22af36b8b..8de33126a 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx @@ -1,13 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { VegaWalletContext } from '@vegaprotocol/wallet'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { generateMarket, generateMarketData } from '../../test-helpers'; import { DealTicket } from './deal-ticket'; import * as Schema from '@vegaprotocol/types'; import type { MockedResponse } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing'; -import { addDecimal } from '@vegaprotocol/utils'; import type { OrdersQuery } from '@vegaprotocol/orders'; import { DealTicketType, @@ -15,6 +20,7 @@ import { } from '../../hooks/use-form-values'; import * as positionsTools from '@vegaprotocol/positions'; import { OrdersDocument } from '@vegaprotocol/orders'; +import { formatForInput } from '@vegaprotocol/utils'; jest.mock('zustand'); jest.mock('./deal-ticket-fee-details', () => ({ @@ -135,20 +141,6 @@ describe('DealTicket', () => { ); }); - it('should display last price for market type order', () => { - render(generateJsx()); - act(() => { - screen.getByTestId('order-type-Market').click(); - }); - // Assert last price is shown - expect(screen.getByTestId('last-price')).toHaveTextContent( - // eslint-disable-next-line - `~${addDecimal(marketPrice, market.decimalPlaces)} ${ - market.tradableInstrument.instrument.product.quoteName - }` - ); - }); - it('should use local storage state for initial values', () => { const expectedOrder = { marketId: market.id, @@ -329,7 +321,7 @@ describe('DealTicket', () => { expect(screen.getByTestId('iceberg')).toBeChecked(); }); - it('should set values for a non-persistent iceberg order and disable post only checkbox', () => { + it('should set values for a non-persistent order and disable post only checkbox', () => { const expectedOrder = { marketId: market.id, type: Schema.OrderType.TYPE_LIMIT, @@ -372,6 +364,7 @@ describe('DealTicket', () => { expect(screen.getByTestId('reduce-only')).not.toBeChecked(); expect(screen.getByTestId('post-only')).not.toBeChecked(); expect(screen.getByTestId('iceberg')).not.toBeChecked(); + expect(screen.getByTestId('iceberg')).toBeDisabled(); }); // eslint-disable-next-line jest/no-disabled-tests @@ -488,4 +481,150 @@ describe('DealTicket', () => { Object.keys(Schema.OrderTimeInForce).length ); }); + + it('validates size field', async () => { + render(generateJsx()); + const sizeErrorMessage = 'deal-ticket-error-message-size'; + const sizeInput = 'order-size'; + await userEvent.click(screen.getByTestId('place-order')); + // 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 () => { + const priceErrorMessage = 'deal-ticket-error-message-price'; + const priceInput = 'order-price'; + const submitButton = 'place-order'; + const orderTypeMarket = 'order-type-Market'; + const orderTypeLimit = 'order-type-Limit'; + render(generateJsx()); + + await userEvent.click(screen.getByTestId(submitButton)); + + 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(orderTypeMarket)); + await userEvent.click(screen.getByTestId(submitButton)); + expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + + // switch back to limit type + await userEvent.click(screen.getByTestId(orderTypeLimit)); + await userEvent.click(screen.getByTestId(submitButton)); + 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 iceberg field', async () => { + const peakSizeErrorMessage = 'deal-ticket-peak-error-message'; + const minimumSizeErrorMessage = 'deal-ticket-minimum-error-message'; + const sizeInput = 'order-size'; + const peakSizeInput = 'order-peak-size'; + const minimumSizeInput = 'order-minimum-size'; + const submitButton = 'place-order'; + + render(generateJsx()); + await userEvent.selectOptions( + screen.getByTestId('order-tif'), + Schema.OrderTimeInForce.TIME_IN_FORCE_GFA + ); + await userEvent.click(screen.getByTestId('iceberg')); + await userEvent.click(screen.getByTestId(submitButton)); + + // validate empty fields + expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument(); + expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument(); + + await userEvent.type(screen.getByTestId(peakSizeInput), '0.01'); + await userEvent.type(screen.getByTestId(minimumSizeInput), '0.01'); + + // validate value smaller than step + expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument(); + expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument(); + + await userEvent.clear(screen.getByTestId(peakSizeInput)); + await userEvent.type(screen.getByTestId(peakSizeInput), '0.5'); + await userEvent.clear(screen.getByTestId(minimumSizeInput)); + await userEvent.type(screen.getByTestId(minimumSizeInput), '0.7'); + + await userEvent.clear(screen.getByTestId(sizeInput)); + await userEvent.type(screen.getByTestId(sizeInput), '0.1'); + + // validate value higher than size + expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument(); + expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument(); + + await userEvent.clear(screen.getByTestId(sizeInput)); + await userEvent.type(screen.getByTestId(sizeInput), '1'); + // validate peak higher than minimum + expect(screen.queryByTestId(peakSizeErrorMessage)).toBeNull(); + expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument(); + + await userEvent.clear(screen.getByTestId(peakSizeInput)); + await userEvent.type(screen.getByTestId(peakSizeInput), '1'); + await userEvent.clear(screen.getByTestId(minimumSizeInput)); + await userEvent.type(screen.getByTestId(minimumSizeInput), '1'); + + // validate correct values + expect(screen.queryByTestId(peakSizeErrorMessage)).toBeNull(); + expect(screen.queryByTestId(minimumSizeErrorMessage)).toBeNull(); + }); + + it('sets expiry time/date to now if expiry is changed to checked', async () => { + const datePicker = 'date-picker-field'; + const now = Math.round(Date.now() / 1000) * 1000; + render(generateJsx()); + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now); + await userEvent.selectOptions( + screen.getByTestId('order-tif'), + Schema.OrderTimeInForce.TIME_IN_FORCE_GTT + ); + + // expiry time/date was empty it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); + + // set to the value in the past (now - 1s) + fireEvent.change(screen.getByTestId(datePicker), { + target: { value: formatForInput(new Date(now - 1000)) }, + }); + expect( + new Date( + screen.getByTestId(datePicker).value + ).getTime() + 1000 + ).toEqual(now); + + // switch expiry off and on + await userEvent.selectOptions( + screen.getByTestId('order-tif'), + Schema.OrderTimeInForce.TIME_IN_FORCE_GFA + ); + await userEvent.selectOptions( + screen.getByTestId('order-tif'), + Schema.OrderTimeInForce.TIME_IN_FORCE_GTT + ); + // expiry time/date was in the past it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); + }); }); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx index 3e5106ff2..7ec2eba85 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx @@ -3,8 +3,6 @@ import * as Schema from '@vegaprotocol/types'; import type { FormEventHandler } from 'react'; import { memo, useCallback, useEffect, useRef, useMemo } from 'react'; import { Controller, useController, useForm } from 'react-hook-form'; -import { DealTicketAmount } from './deal-ticket-amount'; -import { DealTicketButton } from './deal-ticket-button'; import { DealTicketFeeDetails, DealTicketMarginDetails, @@ -17,22 +15,31 @@ import type { OrderSubmission } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet'; import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission'; import { - TradingCheckbox, - TradingInputError, + TradingInput as Input, + TradingCheckbox as Checkbox, + TradingFormGroup as FormGroup, + TradingInputError as InputError, Intent, Notification, Tooltip, + TradingButton as Button, + Pill, } from '@vegaprotocol/ui-toolkit'; import { useEstimatePositionQuery, useOpenVolume, } from '@vegaprotocol/positions'; -import { toBigNum, removeDecimal } from '@vegaprotocol/utils'; +import { + toBigNum, + removeDecimal, + validateAmount, + toDecimal, + formatForInput, + formatValue, +} from '@vegaprotocol/utils'; import { activeOrdersProvider } from '@vegaprotocol/orders'; import { getDerivedPrice } from '@vegaprotocol/markets'; -import type { OrderInfo } from '@vegaprotocol/types'; - import { validateExpiration, validateMarketState, @@ -41,7 +48,10 @@ import { validateType, } from '../../utils'; import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; -import { SummaryValidationType } from '../../constants'; +import { + NOTIONAL_SIZE_TOOLTIP_TEXT, + SummaryValidationType, +} from '../../constants'; import type { Market, MarketData, @@ -52,8 +62,6 @@ import { useMarketAccountBalance, useAccountBalance, } from '@vegaprotocol/accounts'; - -import { OrderType } from '@vegaprotocol/types'; import { useDataProvider } from '@vegaprotocol/data-provider'; import { DealTicketType, @@ -64,6 +72,8 @@ import type { OrderFormValues } from '../../hooks/use-form-values'; import { useDealTicketFormValues } from '../../hooks/use-form-values'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import noop from 'lodash/noop'; +import { isNonPersistentOrder } from '../../utils/time-in-force-persistance'; +import { KeyValue } from './key-value'; 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.'; @@ -114,6 +124,11 @@ const getDefaultValues = ( ...storedValues, }); +export const getAssetUnit = (tags?: string[] | null) => + tags + ?.find((tag) => tag.startsWith('base:') || tag.startsWith('ticker:')) + ?.replace(/^[^:]*:/, ''); + export const DealTicket = ({ market, onMarketClick, @@ -168,6 +183,7 @@ export const DealTicket = ({ const rawPrice = watch('price'); const iceberg = watch('iceberg'); const peakSize = watch('peakSize'); + const expiresAt = watch('expiresAt'); useEffect(() => { const size = storedFormValues?.[dealTicketType]?.size; @@ -222,8 +238,8 @@ export const DealTicket = ({ }); const openVolume = useOpenVolume(pubKey, market.id) ?? '0'; const orders = activeOrders - ? activeOrders.map((order) => ({ - isMarketOrder: order.type === OrderType.TYPE_MARKET, + ? activeOrders.map((order) => ({ + isMarketOrder: order.type === Schema.OrderType.TYPE_MARKET, price: order.price, remaining: order.remaining, side: order.side, @@ -231,7 +247,7 @@ export const DealTicket = ({ : []; if (normalizedOrder) { orders.push({ - isMarketOrder: normalizedOrder.type === OrderType.TYPE_MARKET, + isMarketOrder: normalizedOrder.type === Schema.OrderType.TYPE_MARKET, price: normalizedOrder.price ?? '0', remaining: normalizedOrder.size, side: normalizedOrder.side, @@ -252,6 +268,10 @@ export const DealTicket = ({ const assetSymbol = market.tradableInstrument.instrument.product.settlementAsset.symbol; + const assetUnit = getAssetUnit( + market.tradableInstrument.instrument.metadata.tags + ); + const summaryError = useMemo(() => { if (!pubKey) { return { @@ -299,12 +319,10 @@ export const DealTicket = ({ pubKey, ]); - const disablePostOnlyCheckbox = [ - Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, - Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, - ].includes(timeInForce); - - const disableReduceOnlyCheckbox = !disablePostOnlyCheckbox; + const nonPersistentOrder = isNonPersistentOrder(timeInForce); + const disablePostOnlyCheckbox = nonPersistentOrder; + const disableReduceOnlyCheckbox = !nonPersistentOrder; + const disableIcebergCheckbox = nonPersistentOrder; const onSubmit = useCallback( (formValues: OrderFormValues) => { @@ -332,6 +350,11 @@ export const DealTicket = ({ }, }); + const priceStep = toDecimal(market?.decimalPlaces); + const sizeStep = toDecimal(market?.positionDecimalPlaces); + const quoteName = market.tradableInstrument.instrument.product.quoteName; + const isLimitType = type === Schema.OrderType.TYPE_LIMIT; + return (
    )} /> - ( +
    + + {assetUnit}} + step={sizeStep} + min={sizeStep} + data-testid="order-size" + onWheel={(e) => e.currentTarget.blur()} + {...field} + /> + + {fieldState.error && ( + + {fieldState.error.message} + + )} +
    + )} /> + {isLimitType && ( + ( +
    + + {quoteName}} + className="w-full" + type="number" + step={priceStep} + data-testid="order-price" + onWheel={(e) => e.currentTarget.blur()} + {...field} + /> + + {fieldState.error && ( + + {fieldState.error.message} + + )} +
    + )} + /> + )} +
    + + +
    { + // If GTT is selected and no expiresAt time is set, or its + // behind current time then reset the value to current time + if ( + value === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && + (!expiresAt || new Date(expiresAt).getTime() < Date.now()) + ) { + setValue('expiresAt', formatForInput(new Date()), { + shouldValidate: true, + }); + } + + // iceberg orders must be persistent orders, so if user + // switches to a non persistent tif value, remove iceberg selection + if (iceberg && isNonPersistentOrder(value)) { + setValue('iceberg', false); + } + field.onChange(value); + }} market={market} marketData={marketData} errorMessage={errors.timeInForce?.message} /> )} /> - {type === Schema.OrderType.TYPE_LIMIT && + {isLimitType && timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && ( ( @@ -412,12 +536,12 @@ export const DealTicket = ({ )} /> )} -
    +
    ( - ( -
    - {type === Schema.OrderType.TYPE_LIMIT && ( + {isLimitType && ( <> -
    +
    ( - - - + - + { 'You need to connect your own wallet to start trading on this market' } - +
    ); } @@ -613,9 +752,9 @@ const SummaryMessage = memo( if (error?.message) { return (
    - + {error?.message} - +
    ); } diff --git a/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx index b174fcbda..e1fed1e60 100644 --- a/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx @@ -18,30 +18,30 @@ export const ExpirySelector = ({ onSelect, errorMessage, }: ExpirySelectorProps) => { - const now = useRef(new Date()); - const date = value ? new Date(value) : now.current; - const dateFormatted = formatForInput(date); - const minDate = formatForInput(date); + const minDateRef = useRef(new Date()); + return ( - - onSelect(e.target.value)} - min={minDate} - hasError={!!errorMessage} - /> - {errorMessage && ( - - {errorMessage} - - )} - +
    + + onSelect(e.target.value)} + min={formatForInput(minDateRef.current)} + hasError={!!errorMessage} + /> + {errorMessage && ( + + {errorMessage} + + )} + +
    ); }; diff --git a/libs/deal-ticket/src/components/deal-ticket/index.ts b/libs/deal-ticket/src/components/deal-ticket/index.ts index 8c65c8c77..96b2dc58f 100644 --- a/libs/deal-ticket/src/components/deal-ticket/index.ts +++ b/libs/deal-ticket/src/components/deal-ticket/index.ts @@ -1,7 +1,4 @@ -export * from './deal-ticket-amount'; export * from './deal-ticket-container'; -export * from './deal-ticket-limit-amount'; -export * from './deal-ticket-market-amount'; export * from './deal-ticket'; export * from './deal-ticket-stop-order'; export * from './deal-ticket-container'; diff --git a/libs/deal-ticket/src/components/deal-ticket/key-value.tsx b/libs/deal-ticket/src/components/deal-ticket/key-value.tsx new file mode 100644 index 000000000..42f6a5e6f --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket/key-value.tsx @@ -0,0 +1,51 @@ +import { Tooltip } from '@vegaprotocol/ui-toolkit'; +import classnames from 'classnames'; +import type { ReactNode } from 'react'; + +export interface KeyValuePros { + label: string; + value?: string | null | undefined; + symbol: string; + indent?: boolean | undefined; + labelDescription?: ReactNode; + formattedValue?: string; + onClick?: () => void; +} + +export const KeyValue = ({ + label, + value, + labelDescription, + symbol, + indent, + onClick, + formattedValue, +}: KeyValuePros) => { + const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`; + const valueElement = onClick ? ( + + ) : ( +
    {displayValue}
    + ); + return ( +
    + +
    {label}
    +
    + + {valueElement} + +
    + ); +}; diff --git a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx index 7fefd689c..8fc22bc6d 100644 --- a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx @@ -90,32 +90,34 @@ export const TimeInForceSelector = ({ }; return ( - - { - onSelect(e.target.value as Schema.OrderTimeInForce); - }} - className="w-full" - data-testid="order-tif" - hasError={!!errorMessage} +
    + - {options.map(([key, value]) => ( - - ))} - - {errorMessage && ( - - {renderError(errorMessage)} - - )} - + { + onSelect(e.target.value as Schema.OrderTimeInForce); + }} + className="w-full" + data-testid="order-tif" + hasError={!!errorMessage} + > + {options.map(([key, value]) => ( + + ))} + + {errorMessage && ( + + {renderError(errorMessage)} + + )} + +
    ); }; diff --git a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx index 55ce9ef61..f6fd398f0 100644 --- a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx @@ -76,7 +76,7 @@ export const TypeToggle = ({ ()( })), { name: 'vega_deal_ticket_store', + version: 1, } ) ) diff --git a/libs/deal-ticket/src/utils/map-form-values-to-submission.ts b/libs/deal-ticket/src/utils/map-form-values-to-submission.ts index bf6885900..4f73d2989 100644 --- a/libs/deal-ticket/src/utils/map-form-values-to-submission.ts +++ b/libs/deal-ticket/src/utils/map-form-values-to-submission.ts @@ -9,6 +9,7 @@ import type { } from '../hooks/use-form-values'; import * as Schema from '@vegaprotocol/types'; import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; +import { isPersistentOrder } from './time-in-force-persistance'; export const mapFormValuesToOrderSubmission = ( order: OrderFormValues, @@ -41,11 +42,8 @@ export const mapFormValuesToOrderSubmission = ( ? false : order.reduceOnly, icebergOpts: - (order.type === Schema.OrderType.TYPE_MARKET || - [ - Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, - Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(order.timeInForce)) && + order.type === Schema.OrderType.TYPE_LIMIT && + isPersistentOrder(order.timeInForce) && order.iceberg && order.peakSize && order.minimumVisibleSize @@ -59,6 +57,22 @@ export const mapFormValuesToOrderSubmission = ( : undefined, }); +const setTrigger = ( + stopOrderSetup: StopOrderSetup, + triggerType: StopOrderFormValues['triggerPrice'], + triggerPrice: StopOrderFormValues['triggerPrice'], + triggerTrailingPercentOffset: StopOrderFormValues['triggerTrailingPercentOffset'], + decimalPlaces: number +) => { + if (triggerType === 'price') { + stopOrderSetup.price = removeDecimal(triggerPrice ?? '', decimalPlaces); + } else if (triggerType === 'trailingPercentOffset') { + stopOrderSetup.trailingPercentOffset = ( + Number(triggerTrailingPercentOffset) / 100 + ).toFixed(3); + } +}; + export const mapFormValuesToStopOrdersSubmission = ( data: StopOrderFormValues, marketId: string, @@ -81,31 +95,46 @@ export const mapFormValuesToStopOrdersSubmission = ( positionDecimalPlaces ), }; - if (data.triggerType === 'price') { - stopOrderSetup.price = removeDecimal( - data.triggerPrice ?? '', + setTrigger( + stopOrderSetup, + data.triggerType, + data.triggerPrice, + data.triggerTrailingPercentOffset, + decimalPlaces + ); + let oppositeStopOrderSetup: StopOrderSetup | undefined = undefined; + if (data.oco) { + oppositeStopOrderSetup = { + orderSubmission: mapFormValuesToOrderSubmission( + { + type: data.ocoType, + side: data.side, + size: data.ocoSize, + timeInForce: data.ocoTimeInForce, + price: data.ocoPrice, + reduceOnly: true, + }, + marketId, + decimalPlaces, + positionDecimalPlaces + ), + }; + setTrigger( + oppositeStopOrderSetup, + data.ocoTriggerType, + data.ocoTriggerPrice, + data.ocoTriggerTrailingPercentOffset, decimalPlaces ); - } else if (data.triggerType === 'trailingPercentOffset') { - stopOrderSetup.trailingPercentOffset = ( - Number(data.triggerTrailingPercentOffset) / 100 - ).toFixed(3); } if (data.expire) { - stopOrderSetup.expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt); - if ( - data.expiryStrategy === - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS - ) { - stopOrderSetup.expiryStrategy = - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS; - } else if ( - data.expiryStrategy === - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT - ) { - stopOrderSetup.expiryStrategy = - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT; + const expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt); + stopOrderSetup.expiresAt = expiresAt; + stopOrderSetup.expiryStrategy = data.expiryStrategy; + if (oppositeStopOrderSetup) { + oppositeStopOrderSetup.expiresAt = expiresAt; + oppositeStopOrderSetup.expiryStrategy = data.expiryStrategy; } } @@ -114,12 +143,14 @@ export const mapFormValuesToStopOrdersSubmission = ( Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE ) { submission.risesAbove = stopOrderSetup; + submission.fallsBelow = oppositeStopOrderSetup; } if ( data.triggerDirection === Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW ) { submission.fallsBelow = stopOrderSetup; + submission.risesAbove = oppositeStopOrderSetup; } return submission; diff --git a/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts b/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts index b3c743603..2afccaf2c 100644 --- a/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts +++ b/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts @@ -1,6 +1,8 @@ import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import { mapFormValuesToOrderSubmission } from './map-form-values-to-submission'; import * as Schema from '@vegaprotocol/types'; +import { OrderTimeInForce, OrderType } from '@vegaprotocol/types'; +import type { OrderFormValues } from '../hooks'; describe('mapFormValuesToOrderSubmission', () => { it('sets and formats price only for limit orders', () => { @@ -25,7 +27,7 @@ describe('mapFormValuesToOrderSubmission', () => { ).toEqual('10000'); }); - it('sets and formats expiresAt only for time in force orders', () => { + it('sets and formats expiresAt only for GTT orders', () => { expect( mapFormValuesToOrderSubmission( { @@ -49,6 +51,41 @@ describe('mapFormValuesToOrderSubmission', () => { ).toEqual('1640995200000000000'); }); + it('sets and formats icebergOpts only for persisted orders', () => { + expect( + mapFormValuesToOrderSubmission( + { + type: OrderType.TYPE_LIMIT, + timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK, + iceberg: true, + peakSize: '10.00', + minimumVisibleSize: '10.00', + } as OrderFormValues, + 'marketId', + 2, + 2 + ).icebergOpts + ).toEqual(undefined); + + expect( + mapFormValuesToOrderSubmission( + { + type: OrderType.TYPE_LIMIT, + timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC, + iceberg: true, + peakSize: '10.00', + minimumVisibleSize: '10.00', + } as OrderFormValues, + 'marketId', + 2, + 2 + ).icebergOpts + ).toEqual({ + peakSize: '1000', + minimumVisibleSize: '1000', + }); + }); + it('formats size', () => { expect( mapFormValuesToOrderSubmission( diff --git a/libs/deal-ticket/src/utils/time-in-force-persistance.spec.ts b/libs/deal-ticket/src/utils/time-in-force-persistance.spec.ts new file mode 100644 index 000000000..ce4a783fe --- /dev/null +++ b/libs/deal-ticket/src/utils/time-in-force-persistance.spec.ts @@ -0,0 +1,23 @@ +import { OrderTimeInForce } from '@vegaprotocol/types'; +import { + isNonPersistentOrder, + isPersistentOrder, +} from './time-in-force-persistance'; + +it('isNonPeristentOrder', () => { + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(true); + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(true); + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(false); + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTT)).toBe(false); + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFA)).toBe(false); + expect(isNonPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFN)).toBe(false); +}); + +it('isPeristentOrder', () => { + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_FOK)).toBe(false); + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_IOC)).toBe(false); + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTC)).toBe(true); + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GTT)).toBe(true); + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFA)).toBe(true); + expect(isPersistentOrder(OrderTimeInForce.TIME_IN_FORCE_GFN)).toBe(true); +}); diff --git a/libs/deal-ticket/src/utils/time-in-force-persistance.ts b/libs/deal-ticket/src/utils/time-in-force-persistance.ts new file mode 100644 index 000000000..bfb21c8d6 --- /dev/null +++ b/libs/deal-ticket/src/utils/time-in-force-persistance.ts @@ -0,0 +1,12 @@ +import { OrderTimeInForce } from '@vegaprotocol/types'; + +export const isNonPersistentOrder = (timeInForce: OrderTimeInForce) => { + return [ + OrderTimeInForce.TIME_IN_FORCE_FOK, + OrderTimeInForce.TIME_IN_FORCE_IOC, + ].includes(timeInForce); +}; + +export const isPersistentOrder = (timeInForce: OrderTimeInForce) => { + return !isNonPersistentOrder(timeInForce); +}; diff --git a/libs/environment/src/hooks/use-environment.ts b/libs/environment/src/hooks/use-environment.ts index 2a39c6625..deb5b4b2b 100644 --- a/libs/environment/src/hooks/use-environment.ts +++ b/libs/environment/src/hooks/use-environment.ts @@ -408,6 +408,12 @@ function compileFeatureFlags(): FeatureFlags { process.env['NX_PRODUCT_PERPETUALS'] ) as string ), + METAMASK_SNAPS: TRUTHY.includes( + windowOrDefault( + 'NX_METAMASK_SNAPS', + process.env['NX_METAMASK_SNAPS'] + ) as string + ), }; const EXPLORER_FLAGS = { EXPLORER_ASSETS: TRUTHY.includes( diff --git a/libs/environment/src/types.ts b/libs/environment/src/types.ts index 2c5437ed4..b1b971888 100644 --- a/libs/environment/src/types.ts +++ b/libs/environment/src/types.ts @@ -18,7 +18,11 @@ export type Environment = z.infer; export type FeatureFlags = z.infer; export type CosmicElevatorFlags = Pick< FeatureFlags, - 'ICEBERG_ORDERS' | 'STOP_ORDERS' | 'SUCCESSOR_MARKETS' | 'PRODUCT_PERPETUALS' + | 'ICEBERG_ORDERS' + | 'STOP_ORDERS' + | 'SUCCESSOR_MARKETS' + | 'PRODUCT_PERPETUALS' + | 'METAMASK_SNAPS' >; export type Configuration = z.infer; export const CUSTOM_NODE_KEY = 'custom' as const; diff --git a/libs/environment/src/utils/validate-environment.ts b/libs/environment/src/utils/validate-environment.ts index baf102b14..c0589e73f 100644 --- a/libs/environment/src/utils/validate-environment.ts +++ b/libs/environment/src/utils/validate-environment.ts @@ -77,6 +77,7 @@ const COSMIC_ELEVATOR_FLAGS = { STOP_ORDERS: z.optional(z.boolean()), ICEBERG_ORDERS: z.optional(z.boolean()), PRODUCT_PERPETUALS: z.optional(z.boolean()), + METAMASK_SNAPS: z.optional(z.boolean()), }; const EXPLORER_FLAGS = { diff --git a/libs/ledger/src/index.ts b/libs/ledger/src/index.ts index 16820c1b9..6bd8d83a5 100644 --- a/libs/ledger/src/index.ts +++ b/libs/ledger/src/index.ts @@ -1,2 +1 @@ -export * from './lib/ledger-manager'; -export * from './lib/__generated__/LedgerEntries'; +export * from './lib/ledger-export-form'; diff --git a/libs/ledger/src/lib/LedgerEntries.graphql b/libs/ledger/src/lib/LedgerEntries.graphql deleted file mode 100644 index 354020f7c..000000000 --- a/libs/ledger/src/lib/LedgerEntries.graphql +++ /dev/null @@ -1,47 +0,0 @@ -fragment LedgerEntry on AggregatedLedgerEntry { - vegaTime - quantity - assetId - transferType - toAccountType - toAccountMarketId - toAccountPartyId - toAccountBalance - fromAccountType - fromAccountMarketId - fromAccountPartyId - fromAccountBalance -} - -query LedgerEntries( - $partyId: ID! - $pagination: Pagination - $dateRange: DateRange - $fromAccountType: [AccountType!] - $toAccountType: [AccountType!] -) { - ledgerEntries( - filter: { - FromAccountFilter: { - partyIds: [$partyId] - accountTypes: $fromAccountType - } - ToAccountFilter: { partyIds: [$partyId], accountTypes: $toAccountType } - } - pagination: $pagination - dateRange: $dateRange - ) { - edges { - node { - ...LedgerEntry - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } -} diff --git a/libs/ledger/src/lib/__generated__/LedgerEntries.ts b/libs/ledger/src/lib/__generated__/LedgerEntries.ts deleted file mode 100644 index 2cbcb25bc..000000000 --- a/libs/ledger/src/lib/__generated__/LedgerEntries.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as Types from '@vegaprotocol/types'; - -import { gql } from '@apollo/client'; -import * as Apollo from '@apollo/client'; -const defaultOptions = {} as const; -export type LedgerEntryFragment = { __typename?: 'AggregatedLedgerEntry', vegaTime: any, quantity: string, assetId?: string | null, transferType?: Types.TransferType | null, toAccountType?: Types.AccountType | null, toAccountMarketId?: string | null, toAccountPartyId?: string | null, toAccountBalance: string, fromAccountType?: Types.AccountType | null, fromAccountMarketId?: string | null, fromAccountPartyId?: string | null, fromAccountBalance: string }; - -export type LedgerEntriesQueryVariables = Types.Exact<{ - partyId: Types.Scalars['ID']; - pagination?: Types.InputMaybe; - dateRange?: Types.InputMaybe; - fromAccountType?: Types.InputMaybe | Types.AccountType>; - toAccountType?: Types.InputMaybe | Types.AccountType>; -}>; - - -export type LedgerEntriesQuery = { __typename?: 'Query', ledgerEntries: { __typename?: 'AggregatedLedgerEntriesConnection', edges: Array<{ __typename?: 'AggregatedLedgerEntriesEdge', cursor: string, node: { __typename?: 'AggregatedLedgerEntry', vegaTime: any, quantity: string, assetId?: string | null, transferType?: Types.TransferType | null, toAccountType?: Types.AccountType | null, toAccountMarketId?: string | null, toAccountPartyId?: string | null, toAccountBalance: string, fromAccountType?: Types.AccountType | null, fromAccountMarketId?: string | null, fromAccountPartyId?: string | null, fromAccountBalance: string } } | null>, pageInfo: { __typename?: 'PageInfo', startCursor: string, endCursor: string, hasNextPage: boolean, hasPreviousPage: boolean } } }; - -export const LedgerEntryFragmentDoc = gql` - fragment LedgerEntry on AggregatedLedgerEntry { - vegaTime - quantity - assetId - transferType - toAccountType - toAccountMarketId - toAccountPartyId - toAccountBalance - fromAccountType - fromAccountMarketId - fromAccountPartyId - fromAccountBalance -} - `; -export const LedgerEntriesDocument = gql` - query LedgerEntries($partyId: ID!, $pagination: Pagination, $dateRange: DateRange, $fromAccountType: [AccountType!], $toAccountType: [AccountType!]) { - ledgerEntries( - filter: {FromAccountFilter: {partyIds: [$partyId], accountTypes: $fromAccountType}, ToAccountFilter: {partyIds: [$partyId], accountTypes: $toAccountType}} - pagination: $pagination - dateRange: $dateRange - ) { - edges { - node { - ...LedgerEntry - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } -} - ${LedgerEntryFragmentDoc}`; - -/** - * __useLedgerEntriesQuery__ - * - * To run a query within a React component, call `useLedgerEntriesQuery` and pass it any options that fit your needs. - * When your component renders, `useLedgerEntriesQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useLedgerEntriesQuery({ - * variables: { - * partyId: // value for 'partyId' - * pagination: // value for 'pagination' - * dateRange: // value for 'dateRange' - * fromAccountType: // value for 'fromAccountType' - * toAccountType: // value for 'toAccountType' - * }, - * }); - */ -export function useLedgerEntriesQuery(baseOptions: Apollo.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(LedgerEntriesDocument, options); - } -export function useLedgerEntriesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(LedgerEntriesDocument, options); - } -export type LedgerEntriesQueryHookResult = ReturnType; -export type LedgerEntriesLazyQueryHookResult = ReturnType; -export type LedgerEntriesQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/ledger/src/lib/ledger-entries-data-provider.ts b/libs/ledger/src/lib/ledger-entries-data-provider.ts deleted file mode 100644 index 3119875ef..000000000 --- a/libs/ledger/src/lib/ledger-entries-data-provider.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Asset } from '@vegaprotocol/assets'; -import { assetsMapProvider } from '@vegaprotocol/assets'; -import type { Market } from '@vegaprotocol/markets'; -import { marketsMapProvider } from '@vegaprotocol/markets'; -import { - makeDataProvider, - makeDerivedDataProvider, -} from '@vegaprotocol/data-provider'; - -import type { - LedgerEntriesQuery, - LedgerEntriesQueryVariables, - LedgerEntryFragment, -} from './__generated__/LedgerEntries'; -import { LedgerEntriesDocument } from './__generated__/LedgerEntries'; - -export type LedgerEntry = LedgerEntryFragment & { - asset: Asset | null | undefined; - marketSender: Market | null | undefined; - marketReceiver: Market | null | undefined; -}; - -type Edge = LedgerEntriesQuery['ledgerEntries']['edges'][number]; - -const isLedgerEntryEdge = (entry: Edge): entry is NonNullable => - entry !== null; - -const getData = (responseData: LedgerEntriesQuery | null) => { - return ( - responseData?.ledgerEntries?.edges - .filter(isLedgerEntryEdge) - .map((edge) => edge.node) || [] - ); -}; - -const ledgerEntriesOnlyProvider = makeDataProvider< - LedgerEntriesQuery, - ReturnType, - never, - never, - LedgerEntriesQueryVariables ->({ - query: LedgerEntriesDocument, - getData, - additionalContext: { - isEnlargedTimeout: true, - }, -}); - -export const ledgerEntriesProvider = makeDerivedDataProvider< - LedgerEntry[], - never, - LedgerEntriesQueryVariables ->( - [ - ledgerEntriesOnlyProvider, - (callback, client) => assetsMapProvider(callback, client, undefined), - (callback, client) => marketsMapProvider(callback, client, undefined), - ], - (partsData) => { - const entries = partsData[0] as ReturnType; - const assets = partsData[1] as Record; - const markets = partsData[2] as Record; - return entries.map((entry) => { - const asset = entry.assetId - ? (assets as Record)[entry.assetId] - : null; - const marketSender = entry.fromAccountMarketId - ? markets[entry.fromAccountMarketId] - : null; - const marketReceiver = entry.toAccountMarketId - ? markets[entry.toAccountMarketId] - : null; - return { ...entry, asset, marketSender, marketReceiver }; - }); - } -); diff --git a/libs/ledger/src/lib/ledger-entries.mock.ts b/libs/ledger/src/lib/ledger-entries.mock.ts deleted file mode 100644 index 1033a8c0c..000000000 --- a/libs/ledger/src/lib/ledger-entries.mock.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - LedgerEntriesQuery, - LedgerEntryFragment, -} from './__generated__/LedgerEntries'; -import * as Schema from '@vegaprotocol/types'; -import type { PartialDeep } from 'type-fest'; -import merge from 'lodash/merge'; - -export const ledgerEntriesQuery = ( - override?: PartialDeep -): LedgerEntriesQuery => { - const defaultResult: LedgerEntriesQuery = { - __typename: 'Query', - ledgerEntries: { - __typename: 'AggregatedLedgerEntriesConnection', - edges: ledgerEntries.map((node) => ({ - __typename: 'AggregatedLedgerEntriesEdge', - node, - cursor: 'cursor-1', - })), - pageInfo: { - startCursor: - 'eyJ2ZWdhX3RpbWUiOiIyMDIyLTExLTIzVDE3OjI3OjU2LjczNDM2NFoifQ==', - endCursor: - 'eyJ2ZWdhX3RpbWUiOiIyMDIyLTExLTIzVDEzOjExOjE2LjU0NjM2M1oifQ==', - hasNextPage: false, - hasPreviousPage: false, - __typename: 'PageInfo', - }, - }, - }; - return merge(defaultResult, override); -}; - -export const ledgerEntries: LedgerEntryFragment[] = [ - { - vegaTime: '1669224476734364000', - quantity: '0', - assetId: 'asset-id', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: 'market-1', - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: 'market-0', - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669221452175594000', - quantity: '0', - assetId: 'asset-id-2', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: 'market-0', - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: 'market-2', - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209347054198000', - quantity: '0', - assetId: 'asset-id', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: 'market-3', - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: 'market-2', - fromAccountPartyId: 'sender party id', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209345512806000', - quantity: '0', - assetId: 'asset-id', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209316163397000', - quantity: '0', - assetId: 'asset-id-2', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209299051286000', - quantity: '1326783', - assetId: 'asset-id-2', - transferType: Schema.TransferType.TRANSFER_TYPE_MTM_WIN, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209151328614000', - quantity: '0', - assetId: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209117655380000', - quantity: '0', - assetId: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209082788024000', - quantity: '1326783', - assetId: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c', - transferType: Schema.TransferType.TRANSFER_TYPE_MTM_WIN, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '1669209076546363000', - quantity: '0', - assetId: 'cee709223217281d7893b650850ae8ee8a18b7539b5658f9b4cc24de95dd18ad', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_HIGH, - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '2022-11-24T13:36:42.13989Z', - quantity: '9078407730948615', - assetId: 'cee709223217281d7893b650850ae8ee8a18b7539b5658f9b4cc24de95dd18ad', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_LOW, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - toAccountMarketId: null, - toAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_MARGIN, - fromAccountMarketId: - '0942d767cb2cb5a795e14216e8e53c2b6f75e46dc1732c5aeda8a5aba4ad193d', - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - - { - vegaTime: '2022-11-24T13:35:49.257039Z', - quantity: '263142253070974', - assetId: 'cee709223217281d7893b650850ae8ee8a18b7539b5658f9b4cc24de95dd18ad', - transferType: Schema.TransferType.TRANSFER_TYPE_MARGIN_LOW, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - toAccountMarketId: null, - toAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_MARGIN, - fromAccountMarketId: - '0942d767cb2cb5a795e14216e8e53c2b6f75e46dc1732c5aeda8a5aba4ad193d', - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '2022-11-24T12:41:22.054428Z', - quantity: '1000000000', - assetId: '4e4e80abff30cab933b8c4ac6befc618372eb76b2cbddc337eff0b4a3a4d25b8', - transferType: Schema.TransferType.TRANSFER_TYPE_DEPOSIT, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: null, - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: null, - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '2022-11-24T12:39:11.516154Z', - quantity: '1000000000000', - assetId: 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d', - transferType: Schema.TransferType.TRANSFER_TYPE_DEPOSIT, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: null, - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: null, - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '2022-11-24T12:37:26.832226Z', - quantity: '10000000000000000000000', - assetId: 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d', - transferType: Schema.TransferType.TRANSFER_TYPE_DEPOSIT, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: null, - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: null, - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, - { - vegaTime: '2022-11-24T12:24:52.844901Z', - quantity: '49390000000000000000000', - assetId: 'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d', - transferType: Schema.TransferType.TRANSFER_TYPE_DEPOSIT, - toAccountType: Schema.AccountType.ACCOUNT_TYPE_EXTERNAL, - toAccountMarketId: null, - toAccountPartyId: 'network', - fromAccountType: Schema.AccountType.ACCOUNT_TYPE_GENERAL, - fromAccountMarketId: null, - fromAccountPartyId: - '2e1ef32e5804e14232406aebaad719087d326afa5c648b7824d0823d8a46c8d1', - __typename: 'AggregatedLedgerEntry', - toAccountBalance: '0', - fromAccountBalance: '0', - }, -]; diff --git a/libs/ledger/src/lib/ledger-export-form.spec.tsx b/libs/ledger/src/lib/ledger-export-form.spec.tsx new file mode 100644 index 000000000..37cc66956 --- /dev/null +++ b/libs/ledger/src/lib/ledger-export-form.spec.tsx @@ -0,0 +1,215 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { createDownloadUrl, LedgerExportForm } from './ledger-export-form'; +import { formatForInput, toNanoSeconds } from '@vegaprotocol/utils'; + +const vegaUrl = 'https://vega-url.co.uk/querystuff'; + +const mockResponse = { + headers: { get: jest.fn() }, + blob: () => '', +}; +global.fetch = jest.fn().mockResolvedValue(mockResponse); + +const assetsMock = { + ['a'.repeat(64)]: 'symbol asset-id', + ['b'.repeat(64)]: 'symbol asset-id-2', +}; + +describe('LedgerExportForm', () => { + const partyId = 'c'.repeat(64); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2023-08-10T10:10:10.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be properly rendered', async () => { + render( + + ); + expect(screen.getByText('symbol asset-id')).toBeInTheDocument(); + + // userEvent does not work with faked timers + fireEvent.click(screen.getByTestId('ledger-download-button')); + + expect(screen.getByTestId('download-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ + Object.keys(assetsMock)[0] + }&dateRange.startTimestamp=1691057410000000000` + ); + }); + await waitFor(() => { + expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument(); + }); + }); + + it('assetID should be properly change request url', async () => { + render( + + ); + expect(screen.getByText('symbol asset-id')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('select-ledger-asset'), { + target: { value: Object.keys(assetsMock)[1] }, + }); + + expect(screen.getByText('symbol asset-id-2')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('ledger-download-button')); + + expect(screen.getByTestId('download-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ + Object.keys(assetsMock)[1] + }&dateRange.startTimestamp=1691057410000000000` + ); + }); + await waitFor(() => { + expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument(); + }); + }); + + it('date-from should properly change request url', async () => { + const newDate = new Date(1691230210000); + render( + + ); + expect(screen.getByLabelText('Date from')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Date from'), { + target: { value: formatForInput(newDate) }, + }); + + expect(screen.getByTestId('date-from')).toHaveValue( + `${formatForInput(newDate)}.000` + ); + + fireEvent.click(screen.getByTestId('ledger-download-button')); + + expect(screen.getByTestId('download-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ + Object.keys(assetsMock)[0] + }&dateRange.startTimestamp=${toNanoSeconds(newDate)}` + ); + }); + + await waitFor(() => { + expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument(); + }); + }); + + it('date-to should properly change request url', async () => { + const newDate = new Date(1691230210000); + render( + + ); + + expect(screen.getByLabelText('Date to')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Date to'), { + target: { value: formatForInput(newDate) }, + }); + + expect(screen.getByTestId('date-to')).toHaveValue( + `${formatForInput(newDate)}.000` + ); + + fireEvent.click(screen.getByTestId('ledger-download-button')); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${ + Object.keys(assetsMock)[0] + }&dateRange.startTimestamp=1691057410000000000&dateRange.endTimestamp=${toNanoSeconds( + newDate + )}` + ); + }); + + await waitFor(() => { + expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument(); + }); + }); +}); + +describe('createDownloadUrl', () => { + const fromTimestamp = 1690848000000; + const toTimestamp = 1691107200000; + const args = { + protohost: 'https://vega-url.co.uk', + partyId: 'a'.repeat(64), + assetId: 'b'.repeat(64), + dateFrom: new Date(fromTimestamp).toISOString(), + }; + + it('formats url with without an end date', () => { + expect(createDownloadUrl(args)).toEqual( + `${args.protohost}/api/v2/ledgerentry/export?partyId=${ + args.partyId + }&assetId=${args.assetId}&dateRange.startTimestamp=${toNanoSeconds( + args.dateFrom + )}` + ); + }); + + it('formats url with with an end date', () => { + const dateTo = new Date(toTimestamp).toISOString(); + expect( + createDownloadUrl({ + ...args, + dateTo, + }) + ).toEqual( + `${args.protohost}/api/v2/ledgerentry/export?partyId=${ + args.partyId + }&assetId=${args.assetId}&dateRange.startTimestamp=${toNanoSeconds( + args.dateFrom + )}&dateRange.endTimestamp=${toNanoSeconds(dateTo)}` + ); + }); + + it('should throw if invalid args are provided', () => { + // invalid url + expect(() => { + // @ts-ignore override z.infer type + createDownloadUrl({ ...args, protohost: 'foo' }); + }).toThrow(); + + // invalid partyId + expect(() => { + // @ts-ignore override z.infer type + createDownloadUrl({ ...args, partyId: 'z'.repeat(64) }); + }).toThrow(); + }); +}); diff --git a/libs/ledger/src/lib/ledger-export-form.tsx b/libs/ledger/src/lib/ledger-export-form.tsx new file mode 100644 index 000000000..8ef4fcff2 --- /dev/null +++ b/libs/ledger/src/lib/ledger-export-form.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react'; +import { z } from 'zod'; +import { + Button, + Loader, + TradingFormGroup, + TradingInput, + TradingSelect, +} from '@vegaprotocol/ui-toolkit'; +import { toNanoSeconds, VEGA_ID_REGEX } from '@vegaprotocol/utils'; +import { t } from '@vegaprotocol/i18n'; +import { localLoggerFactory } from '@vegaprotocol/logger'; +import { formatForInput } from '@vegaprotocol/utils'; +import { subDays } from 'date-fns'; + +const DEFAULT_EXPORT_FILE_NAME = 'ledger_entries.csv'; + +const getProtoHost = (vegaurl: string) => { + const loc = new URL(vegaurl); + return `${loc.protocol}//${loc.host}`; +}; + +const downloadSchema = z.object({ + protohost: z.string().url().nonempty(), + partyId: z.string().regex(VEGA_ID_REGEX).nonempty(), + assetId: z.string().regex(VEGA_ID_REGEX).nonempty(), + dateFrom: z.string().nonempty(), + dateTo: z.string().optional(), +}); + +export const createDownloadUrl = (args: z.infer) => { + // check args from form inputs + downloadSchema.parse(args); + + const params = new URLSearchParams(); + params.append('partyId', args.partyId); + params.append('assetId', args.assetId); + params.append('dateRange.startTimestamp', toNanoSeconds(args.dateFrom)); + + if (args.dateTo) { + params.append('dateRange.endTimestamp', toNanoSeconds(args.dateTo)); + } + + const url = new URL(args.protohost); + url.pathname = '/api/v2/ledgerentry/export'; + url.search = params.toString(); + + return url.toString(); +}; + +interface Props { + partyId: string; + vegaUrl: string; + assets: Record; +} + +export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => { + const now = useRef(new Date()); + const [dateFrom, setDateFrom] = useState(() => { + return formatForInput(subDays(now.current, 7)); + }); + const [dateTo, setDateTo] = useState(''); + const maxFromDate = formatForInput(new Date(dateTo || now.current)); + const maxToDate = formatForInput(now.current); + + const [isDownloading, setIsDownloading] = useState(false); + const [assetId, setAssetId] = useState(Object.keys(assets)[0]); + const protohost = getProtoHost(vegaUrl); + const disabled = Boolean(!assetId || isDownloading); + + const assetDropDown = ( + { + setAssetId(e.target.value); + }} + className="w-full" + data-testid="select-ledger-asset" + disabled={isDownloading} + > + {Object.keys(assets).map((assetKey) => ( + + ))} + + ); + + const startDownload = async (event: React.FormEvent) => { + event.preventDefault(); + try { + const link = createDownloadUrl({ + protohost, + partyId, + assetId, + dateFrom, + dateTo, + }); + setIsDownloading(true); + const resp = await fetch(link); + const { headers } = resp; + const nameHeader = headers.get('content-disposition'); + const filename = nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME; + const blob = await resp.blob(); + if (blob) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + } + } catch (err) { + localLoggerFactory({ application: 'ledger' }).error('Download file', err); + } finally { + setIsDownloading(false); + } + }; + + if (!protohost || Object.keys(assets).length === 0) { + return null; + } + + return ( + +

    {t('Export ledger entries')}

    + + {assetDropDown} + + + setDateFrom(e.target.value)} + disabled={disabled} + max={maxFromDate} + /> + + + setDateTo(e.target.value)} + disabled={disabled} + max={maxToDate} + /> + +
    + {isDownloading && ( +
    + +
    + )} + +
    + + ); +}; diff --git a/libs/ledger/src/lib/ledger-export-link.spec.tsx b/libs/ledger/src/lib/ledger-export-link.spec.tsx deleted file mode 100644 index 8c623ccfa..000000000 --- a/libs/ledger/src/lib/ledger-export-link.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { LedgerExportLink } from './ledger-export-link'; -import * as Types from '@vegaprotocol/types'; -import { ledgerEntries } from './ledger-entries.mock'; -import type { LedgerEntry } from './ledger-entries-data-provider'; - -const VEGA_URL = 'https://vega-url.co.uk/querystuff'; -const mockEnvironment = jest.fn(() => VEGA_URL); -jest.mock('@vegaprotocol/environment', () => ({ - useEnvironment: jest.fn(() => mockEnvironment()), -})); - -const asset = { - id: 'assetID', - name: 'assetName', - symbol: 'assetSymbol', - decimals: 1, - quantum: '1', - status: Types.AssetStatus, - source: { - __typename: 'ERC20', - contractAddress: 'contractAddres', - lifetimeLimit: 'lifetimeLimit', - withdrawThreshold: 'withdraw', - }, -}; - -describe('LedgerExportLink', () => { - const partyId = 'partyId'; - const entries = ledgerEntries.map((entry) => { - return { - ...entry, - asset: entry.assetId - ? { - ...asset, - id: entry.assetId, - name: `name ${entry.assetId}`, - symbol: `symbol ${entry.assetId}`, - } - : null, - marketSender: null, - marketReceiver: null, - } as LedgerEntry; - }); - - it('should be properly rendered', async () => { - render(); - await waitFor(() => { - expect(screen.getByRole('link')).toBeInTheDocument(); - expect(screen.getByRole('link')).toHaveAttribute( - 'href', - `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=asset-id` - ); - expect( - screen.getByRole('button', { name: /^symbol asset-id/ }) - ).toBeInTheDocument(); - }); - }); - it('should be properly change link url', async () => { - render(); - await waitFor(() => { - expect( - screen.getByRole('button', { name: /^symbol asset-id/ }) - ).toBeInTheDocument(); - }); - act(() => { - userEvent.click(screen.getByRole('button', { name: /^symbol asset-id/ })); - }); - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - act(() => { - userEvent.click( - screen.getByRole('menuitem', { name: /^symbol asset-id-2/ }) - ); - }); - await waitFor(() => { - expect(screen.getByRole('link')).toHaveAttribute( - 'href', - `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=asset-id-2` - ); - }); - }); -}); diff --git a/libs/ledger/src/lib/ledger-export-link.tsx b/libs/ledger/src/lib/ledger-export-link.tsx deleted file mode 100644 index aaae61863..000000000 --- a/libs/ledger/src/lib/ledger-export-link.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { LedgerEntry } from './ledger-entries-data-provider'; -import { useMemo, useState } from 'react'; -import { useEnvironment } from '@vegaprotocol/environment'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Link, - Button, -} from '@vegaprotocol/ui-toolkit'; -import { t } from '@vegaprotocol/i18n'; - -const getProtoHost = (vegaurl: string) => { - const loc = new URL(vegaurl); - return `${loc.protocol}//${loc.host}`; -}; - -export const LedgerExportLink = ({ - partyId, - entries, -}: { - partyId: string; - entries: LedgerEntry[]; -}) => { - const assets = entries.reduce((aggr, item) => { - if (item.asset && !(item.asset.id in aggr)) { - aggr[item.asset.id] = item.asset.symbol; - } - return aggr; - }, {} as Record); - const [assetId, setAssetId] = useState(Object.keys(assets)[0]); - const VEGA_URL = useEnvironment((store) => store.VEGA_URL); - const protohost = VEGA_URL ? getProtoHost(VEGA_URL) : ''; - - const assetDropDown = useMemo(() => { - return ( - {assets[assetId]}} - > - - {Object.keys(assets).map((assetKey) => ( - setAssetId(assetKey)} - > - {assets[assetKey]} - - ))} - - - ); - }, [assetId, assets]); - - if (!protohost || !entries || entries.length === 0) { - return null; - } - return ( -
    -
    Export all
    - {assetDropDown} - - - -
    - ); -}; diff --git a/libs/ledger/src/lib/ledger-manager.tsx b/libs/ledger/src/lib/ledger-manager.tsx deleted file mode 100644 index 1dd4eb490..000000000 --- a/libs/ledger/src/lib/ledger-manager.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { t } from '@vegaprotocol/i18n'; -import type * as Schema from '@vegaprotocol/types'; -import type { FilterChangedEvent } from 'ag-grid-community'; -import { useCallback, useState, useMemo } from 'react'; -import { subDays, formatRFC3339 } from 'date-fns'; -import { ledgerEntriesProvider } from './ledger-entries-data-provider'; -import type { LedgerEntriesQueryVariables } from './__generated__/LedgerEntries'; -import { LedgerTable } from './ledger-table'; -import { useDataProvider } from '@vegaprotocol/data-provider'; -import type * as Types from '@vegaprotocol/types'; -import { LedgerExportLink } from './ledger-export-link'; -import type { useDataGridEvents } from '@vegaprotocol/datagrid'; - -export interface Filter { - vegaTime?: { - value: Schema.DateRange; - }; - fromAccountType?: { value: Types.AccountType[] }; - toAccountType?: { value: Types.AccountType[] }; -} -const defaultFilter = { - vegaTime: { - value: { start: formatRFC3339(subDays(Date.now(), 7)) }, - }, -}; - -export const LedgerManager = ({ - partyId, - gridProps, -}: { - partyId: string; - gridProps: ReturnType; -}) => { - const [filter, setFilter] = useState(defaultFilter); - - const variables = useMemo( - () => ({ - partyId, - dateRange: filter?.vegaTime?.value, - pagination: { - first: 10, - }, - }), - [partyId, filter?.vegaTime?.value] - ); - - const { data, error } = useDataProvider({ - dataProvider: ledgerEntriesProvider, - variables, - skip: !variables.partyId, - }); - - const onFilterChanged = useCallback( - (event: FilterChangedEvent) => { - const updatedFilter = { ...defaultFilter, ...event.api.getFilterModel() }; - setFilter(updatedFilter); - gridProps.onFilterChanged(event); - }, - [gridProps] - ); - - return ( -
    - - {data && } -
    - ); -}; diff --git a/libs/ledger/src/lib/ledger-table.tsx b/libs/ledger/src/lib/ledger-table.tsx deleted file mode 100644 index c175d3c35..000000000 --- a/libs/ledger/src/lib/ledger-table.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { - addDecimalsFormatNumber, - fromNanoSeconds, - getDateTimeFormat, - truncateByChars, -} from '@vegaprotocol/utils'; -import { t } from '@vegaprotocol/i18n'; -import type { - VegaValueFormatterParams, - TypedDataAgGrid, -} from '@vegaprotocol/datagrid'; -import { - AgGridLazy as AgGrid, - DateRangeFilter, - MarketNameCell, - SetFilter, -} from '@vegaprotocol/datagrid'; -import type * as Types from '@vegaprotocol/types'; -import type { ColDef } from 'ag-grid-community'; -import type { Market } from '@vegaprotocol/markets'; -import { - AccountTypeMapping, - DescriptionTransferTypeMapping, - TransferTypeMapping, -} from '@vegaprotocol/types'; -import type { LedgerEntry } from './ledger-entries-data-provider'; -import { useMemo } from 'react'; -import { formatRFC3339, subDays } from 'date-fns'; - -export const TransferTooltipCellComponent = ({ - value, -}: { - value: Types.TransferType; -}) => { - return ( -

    - {value ? DescriptionTransferTypeMapping[value] : ''} -

    - ); -}; - -const defaultValue = { start: formatRFC3339(subDays(Date.now(), 7)) }; -const dateRangeFilterParams = { - maxNextDays: 0, - defaultValue, -}; -const defaultColDef = { - resizable: true, - sortable: true, - tooltipComponent: TransferTooltipCellComponent, - filterParams: { - ...dateRangeFilterParams, - buttons: ['reset'], - }, -}; - -type LedgerEntryProps = TypedDataAgGrid; - -export const LedgerTable = (props: LedgerEntryProps) => { - const columnDefs = useMemo( - () => [ - { - headerName: t('Sender'), - field: 'fromAccountPartyId', - cellRenderer: ({ - value, - }: VegaValueFormatterParams) => - truncateByChars(value || ''), - }, - { - headerName: t('Account type'), - filter: SetFilter, - filterParams: { - set: AccountTypeMapping, - }, - field: 'fromAccountType', - cellRenderer: ({ - value, - }: VegaValueFormatterParams) => - value ? AccountTypeMapping[value] : '-', - }, - { - headerName: t('Market'), - field: 'marketSender.tradableInstrument.instrument.code', - cellRenderer: ({ - value, - data, - }: VegaValueFormatterParams< - LedgerEntry, - 'marketSender.tradableInstrument.instrument.code' - >) => - value && ( - - ), - }, - { - headerName: t('Receiver'), - field: 'toAccountPartyId', - cellRenderer: ({ - value, - }: VegaValueFormatterParams) => - truncateByChars(value || ''), - }, - { - headerName: t('Account type'), - filter: SetFilter, - filterParams: { - set: AccountTypeMapping, - }, - field: 'toAccountType', - cellRenderer: ({ - value, - }: VegaValueFormatterParams) => - value ? AccountTypeMapping[value] : '-', - }, - { - headerName: t('Market'), - field: 'marketReceiver.tradableInstrument.instrument.code', - cellRenderer: ({ - value, - data, - }: VegaValueFormatterParams< - LedgerEntry, - 'marketReceiver.tradableInstrument.instrument.code' - >) => - value && ( - - ), - }, - { - headerName: t('Transfer type'), - field: 'transferType', - tooltipField: 'transferType', - filter: SetFilter, - filterParams: { - set: TransferTypeMapping, - }, - valueFormatter: ({ - value, - }: VegaValueFormatterParams) => - value ? TransferTypeMapping[value] : '', - }, - { - headerName: t('Quantity'), - field: 'quantity', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => { - const assetDecimalPlaces = data?.asset?.decimals || 0; - return value - ? addDecimalsFormatNumber(value, assetDecimalPlaces) - : ''; - }, - }, - { - headerName: t('Asset'), - field: 'assetId', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => - data?.asset?.symbol || '', - }, - { - headerName: t('Sender account balance'), - field: 'fromAccountBalance', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => { - const assetDecimalPlaces = data?.asset?.decimals || 0; - return value - ? addDecimalsFormatNumber(value, assetDecimalPlaces) - : ''; - }, - }, - { - headerName: t('Receiver account balance'), - field: 'toAccountBalance', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => { - const assetDecimalPlaces = data?.asset?.decimals || 0; - return value - ? addDecimalsFormatNumber(value, assetDecimalPlaces) - : ''; - }, - }, - { - headerName: t('Vega time'), - field: 'vegaTime', - valueFormatter: ({ - value, - }: VegaValueFormatterParams) => - value ? getDateTimeFormat().format(fromNanoSeconds(value)) : '-', - filterParams: dateRangeFilterParams, - filter: DateRangeFilter, - }, - ], - [] - ); - return ( - - ); -}; diff --git a/libs/markets/src/lib/components/last-24h-price-change/last-24h-price-change.tsx b/libs/markets/src/lib/components/last-24h-price-change/last-24h-price-change.tsx index 9d2ae2347..aff79d012 100644 --- a/libs/markets/src/lib/components/last-24h-price-change/last-24h-price-change.tsx +++ b/libs/markets/src/lib/components/last-24h-price-change/last-24h-price-change.tsx @@ -1,8 +1,16 @@ -import { isNumeric } from '@vegaprotocol/utils'; -import { PriceChangeCell } from '@vegaprotocol/datagrid'; -import { Tooltip } from '@vegaprotocol/ui-toolkit'; +import { + addDecimalsFormatNumber, + formatNumberPercentage, + isNumeric, + priceChange, + priceChangePercentage, +} from '@vegaprotocol/utils'; +import { PriceChangeCell, signedNumberCssClass } from '@vegaprotocol/datagrid'; +import { Tooltip, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; import { useCandles } from '../../hooks/use-candles'; +import BigNumber from 'bignumber.js'; +import classNames from 'classnames'; interface Props { marketId?: string; @@ -47,10 +55,39 @@ export const Last24hPriceChange = ({ if (error || !isNumeric(decimalPlaces)) { return -; } + + const candles = oneDayCandles?.map((c) => c.close) || initialValue || []; + const change = priceChange(candles); + const changePercentage = priceChangePercentage(candles); + return ( - c.close) || initialValue || []} - decimalPlaces={decimalPlaces} - /> + + + + {formatNumberPercentage(new BigNumber(changePercentage.toString()), 2)} + + + {addDecimalsFormatNumber(change.toString(), decimalPlaces ?? 0, 3)} + + ); }; + +const Arrow = ({ value }: { value: number | bigint }) => { + const size = 10; + + if (value > 0) { + return ; + } + + if (value < 0) { + return ; + } + + return null; +}; diff --git a/libs/markets/src/lib/components/market-info/info-key-value-table.tsx b/libs/markets/src/lib/components/market-info/info-key-value-table.tsx index e717453bb..483b0aaa9 100644 --- a/libs/markets/src/lib/components/market-info/info-key-value-table.tsx +++ b/libs/markets/src/lib/components/market-info/info-key-value-table.tsx @@ -98,7 +98,7 @@ export const Row = ({
    {valueDiffersFromParentMarket ? (
    - + {getFormattedValue(parentValue)} {formattedValue} diff --git a/libs/markets/src/lib/components/market-info/market-info-panels.tsx b/libs/markets/src/lib/components/market-info/market-info-panels.tsx index 1c84223d3..a1acca924 100644 --- a/libs/markets/src/lib/components/market-info/market-info-panels.tsx +++ b/libs/markets/src/lib/components/market-info/market-info-panels.tsx @@ -1,13 +1,14 @@ import isEqual from 'lodash/isEqual'; import type { ReactNode } from 'react'; -import { Fragment, useState } from 'react'; -import { useMemo } from 'react'; +import { Fragment, useMemo, useState } from 'react'; import { AssetDetailsTable, useAssetDataProvider } from '@vegaprotocol/assets'; import { t } from '@vegaprotocol/i18n'; import { marketDataProvider } from '../../market-data-provider'; import { totalFeesPercentage } from '../../market-utils'; import { ExternalLink, + Intent, + Lozenge, Splash, Tooltip, VegaIcon, @@ -27,9 +28,15 @@ import type { } from './market-info-data-provider'; import { Last24hVolume } from '../last-24h-volume'; import BigNumber from 'bignumber.js'; -import type { DataSourceDefinition, SignerKind } from '@vegaprotocol/types'; -import { ConditionOperatorMapping } from '@vegaprotocol/types'; -import { MarketTradingModeMapping } from '@vegaprotocol/types'; +import type { + DataSourceDefinition, + MarketTradingMode, + SignerKind, +} from '@vegaprotocol/types'; +import { + ConditionOperatorMapping, + MarketTradingModeMapping, +} from '@vegaprotocol/types'; import { DApp, FLAGS, @@ -48,8 +55,6 @@ import { useSuccessorMarketQuery, } from '../../__generated__'; import { useSuccessorMarketProposalDetailsQuery } from '@vegaprotocol/proposals'; -import type { MarketTradingMode } from '@vegaprotocol/types'; -import type { Signer } from '@vegaprotocol/types'; import classNames from 'classnames'; import compact from 'lodash/compact'; @@ -575,7 +580,6 @@ export const RiskFactorsInfoPanel = ({ export const PriceMonitoringBoundsInfoPanel = ({ market, triggerIndex, - parentMarket, }: MarketInfoProps & { triggerIndex: number; }) => { @@ -584,33 +588,13 @@ export const PriceMonitoringBoundsInfoPanel = ({ variables: { marketId: market.id }, }); - const { data: parentData } = useDataProvider({ - dataProvider: marketDataProvider, - variables: { marketId: parentMarket?.id || '' }, - skip: - !parentMarket || - !parentMarket?.priceMonitoringSettings?.parameters?.triggers?.[ - triggerIndex - ], - }); - const quoteUnit = market?.tradableInstrument.instrument.product?.quoteName || ''; - const parentQuoteUnit = - parentMarket?.tradableInstrument.instrument.product?.quoteName || ''; - const isParentQuoteUnitEqual = quoteUnit === parentQuoteUnit; const trigger = market.priceMonitoringSettings?.parameters?.triggers?.[triggerIndex]; - const parentTrigger = - parentMarket?.priceMonitoringSettings?.parameters?.triggers?.[triggerIndex]; - const isParentTriggerEqual = isEqual(trigger, parentTrigger); const bounds = data?.priceMonitoringBounds?.[triggerIndex]; - const parentBounds = parentData?.priceMonitoringBounds?.[triggerIndex]; - - const shouldShowParentData = - isParentQuoteUnitEqual && isParentTriggerEqual && !!parentBounds; if (!trigger) { console.error( @@ -638,14 +622,6 @@ export const PriceMonitoringBoundsInfoPanel = ({ highestPrice: bounds.maxValidPrice, lowestPrice: bounds.minValidPrice, }} - parentData={ - shouldShowParentData - ? { - highestPrice: parentBounds.maxValidPrice, - lowestPrice: parentBounds.minValidPrice, - } - : undefined - } decimalPlaces={market.decimalPlaces} assetSymbol={quoteUnit} /> @@ -839,45 +815,76 @@ export const OracleInfoPanel = ({ : (parentProduct?.dataSourceSpecForTradingTermination ?.data as DataSourceDefinition); - const isParentDataSourceSpecEqual = - parentDataSourceSpec !== undefined && - dataSourceSpec === parentDataSourceSpec; - const isParentDataSourceSpecIdEqual = + const shouldShowParentData = + parentMarket !== undefined && parentDataSourceSpecId !== undefined && - dataSourceSpecId === parentDataSourceSpecId; + !isEqual(dataSourceSpec, parentDataSourceSpec); + + const wrapperClasses = classNames('mb-4', { + 'flex items-center gap-6': shouldShowParentData, + }); - // We'll only provide successor parent data (if it differs) to the - // DataSourceProof component. Having an old external link struck through - // is unlikely to be useful. return ( -
    - + <> + {shouldShowParentData && ( + + {t('Updated')} + + )} - - {type === 'settlementData' - ? t('View settlement data specification') - : t('View termination specification')} - -
    +
    + {shouldShowParentData && + parentDataSourceSpec && + parentDataSourceSpecId && + parentProduct && ( +
    + + + + {type === 'settlementData' + ? t('View settlement data specification') + : t('View termination specification')} + +
    + )} + +
    + + + + {type === 'settlementData' + ? t('View settlement data specification') + : t('View termination specification')} + +
    +
    + ); }; @@ -886,28 +893,14 @@ export const DataSourceProof = ({ providers, type, dataSourceSpecId, - parentData, - parentDataSourceSpecId, }: { data: DataSourceDefinition; providers: Provider[] | undefined; type: 'settlementData' | 'termination'; dataSourceSpecId: string; - parentData?: DataSourceDefinition; - parentDataSourceSpecId?: string; }) => { - // If this is a successor market, we'll only pass parent data to child - // components for comparison if the data differs from the parent market. if (data.sourceType.__typename === 'DataSourceDefinitionExternal') { const signers = data.sourceType.sourceType.signers || []; - let parentSigners: Signer[]; - - if ( - parentData && - parentData.sourceType.__typename === 'DataSourceDefinitionExternal' - ) { - parentSigners = parentData.sourceType.sourceType?.signers || []; - } if (!providers?.length) { return ; @@ -915,34 +908,15 @@ export const DataSourceProof = ({ return (
    - {signers.map(({ signer }, i) => { - const parentSigner = parentSigners?.find( - ({ signer: ParentSigner }) => - ParentSigner.__typename === signer.__typename - )?.signer; - - const isParentSignerEqual = isEqual(signer, parentSigner); - - return isParentSignerEqual ? ( - - ) : ( - - ); - })} + {signers.map(({ signer }, i) => ( + + ))}
    ); } @@ -1002,22 +976,13 @@ const OracleLink = ({ signer, type, dataSourceSpecId, - parentSigner, - parentDataSourceSpecId, }: { providers: Provider[]; signer: SignerKind; type: 'settlementData' | 'termination'; dataSourceSpecId: string; - parentSigner?: SignerKind; - parentDataSourceSpecId?: string; }) => { - // If this is a successor market, the parent market data will only have been passed - // in if it differs from the current data. const signerProviders = getSignerProviders(signer, providers); - const parentSignerProviders = parentSigner - ? getSignerProviders(parentSigner, providers) - : []; if (!signerProviders.length) { return ; @@ -1025,34 +990,13 @@ const OracleLink = ({ return (
    - {signerProviders.map((provider) => { - // Making the assumption here that if the provider name is the same, - // that it is the same provider that the parent market used. - const parentProvider = parentSignerProviders.find( - (p) => p.name === provider.name - ); - - const isParentProviderEqual = - parentProvider !== undefined && isEqual(provider, parentProvider); - - // We only want to pass the parent data to the child component if the - // data differs from the parent market. - return isParentProviderEqual ? ( - - ) : ( - - ); - })} + {signerProviders.map((provider) => ( + + ))}
    ); }; @@ -1075,18 +1019,13 @@ const NoOracleProof = ({ const OracleProfile = (props: { provider: Provider; dataSourceSpecId: string; - parentProvider?: Provider; - parentDataSourceSpecId?: string; }) => { - // If this is a successor market, the parent market data will only have been passed - // in if it differs from the current data. const [open, onChange] = useState(false); return (
    onChange(!open)} - parentProvider={props.parentProvider} />
    diff --git a/libs/markets/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx b/libs/markets/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx index b9243ffc7..a8afd2966 100644 --- a/libs/markets/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx +++ b/libs/markets/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx @@ -5,7 +5,6 @@ import { ExternalLink, Icon, Intent, - Lozenge, VegaIcon, VegaIconNames, } from '@vegaprotocol/ui-toolkit'; @@ -60,12 +59,10 @@ export const OracleBasicProfile = ({ provider, onClick, markets: oracleMarkets, - parentProvider, }: { provider: Provider; markets?: OracleMarketSpecFieldsFragment[] | undefined; onClick?: (value?: boolean) => void; - parentProvider?: Provider; }) => { const { icon, message, intent } = getVerifiedStatusIcon(provider); @@ -81,14 +78,8 @@ export const OracleBasicProfile = ({ icon: getLinkIcon(proof.type), })); - // If this is a successor market and there's a different parent provider, - // we'll just show that there's been a change, rather than add old data - // in alongside the new provider. return ( <> - {parentProvider && ( - {t('Updated')} - )} {provider.url && ( diff --git a/libs/markets/src/lib/market-utils.spec.tsx b/libs/markets/src/lib/market-utils.spec.tsx index a58abcffb..b974098c8 100644 --- a/libs/markets/src/lib/market-utils.spec.tsx +++ b/libs/markets/src/lib/market-utils.spec.tsx @@ -1,6 +1,10 @@ import * as Schema from '@vegaprotocol/types'; -import type { Market } from './markets-provider'; -import { filterAndSortMarkets, totalFeesPercentage } from './market-utils'; +import type { Market, MarketMaybeWithDataAndCandles } from './markets-provider'; +import { + calcTradedFactor, + filterAndSortMarkets, + totalFeesPercentage, +} from './market-utils'; const { MarketState, MarketTradingMode } = Schema; const MARKET_A: Partial = { @@ -77,3 +81,52 @@ describe('totalFees', () => { expect(totalFeesPercentage(i)).toEqual(o); }); }); + +describe('calcTradedFactor', () => { + const marketA = { + data: { + markPrice: '10', + }, + candles: [ + { + volume: '1000', + }, + ], + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + decimals: 18, + quantum: '1000000000000000000', // 1 + }, + }, + }, + }, + }; + const marketB = { + data: { + markPrice: '10', + }, + candles: [ + { + volume: '1000', + }, + ], + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + decimals: 18, + quantum: '1', // 0.0000000000000000001 + }, + }, + }, + }, + }; + it('a is "traded" more than b', () => { + const fa = calcTradedFactor(marketA as MarketMaybeWithDataAndCandles); + const fb = calcTradedFactor(marketB as MarketMaybeWithDataAndCandles); + // it should be true because market a's asset is "more valuable" than b's + expect(fa > fb).toBeTruthy(); + }); +}); diff --git a/libs/markets/src/lib/market-utils.ts b/libs/markets/src/lib/market-utils.ts index 871f2afa4..448acf247 100644 --- a/libs/markets/src/lib/market-utils.ts +++ b/libs/markets/src/lib/market-utils.ts @@ -1,8 +1,13 @@ -import { formatNumberPercentage } from '@vegaprotocol/utils'; +import { formatNumberPercentage, toBigNum } from '@vegaprotocol/utils'; import * as Schema from '@vegaprotocol/types'; import BigNumber from 'bignumber.js'; import orderBy from 'lodash/orderBy'; -import type { Market, Candle, MarketMaybeWithData } from '../'; +import type { + Market, + Candle, + MarketMaybeWithData, + MarketMaybeWithDataAndCandles, +} from '../'; const { MarketState, MarketTradingMode } = Schema; export const totalFees = (fees: Market['fees']['factors']) => { @@ -86,3 +91,18 @@ export const calcCandleHigh = (candles: Candle[]): string | undefined => { export const calcCandleVolume = (candles: Candle[]): string | undefined => candles && candles.reduce((acc, c) => new BigNumber(acc).plus(c.volume).toString(), '0'); + +export const calcTradedFactor = (m: MarketMaybeWithDataAndCandles) => { + const volume = Number(calcCandleVolume(m.candles || []) || 0); + const price = m.data?.markPrice ? Number(m.data.markPrice) : 0; + const quantum = Number( + m.tradableInstrument.instrument.product.settlementAsset.quantum + ); + const decimals = Number( + m.tradableInstrument.instrument.product.settlementAsset.decimals + ); + const fp = toBigNum(price, decimals); + const fq = toBigNum(quantum, decimals); + const factor = fq.multipliedBy(fp).multipliedBy(volume); + return factor.toNumber(); +}; diff --git a/libs/orders/src/lib/components/order-data-provider/Orders.graphql b/libs/orders/src/lib/components/order-data-provider/Orders.graphql index 3d837542a..83c6cf5d7 100644 --- a/libs/orders/src/lib/components/order-data-provider/Orders.graphql +++ b/libs/orders/src/lib/components/order-data-provider/Orders.graphql @@ -128,6 +128,9 @@ fragment StopOrderFields on StopOrder { updatedAt partyId marketId + order { + ...OrderFields + } trigger { ... on StopOrderPrice { price diff --git a/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts b/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts index a4513725e..99896817e 100644 --- a/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts +++ b/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts @@ -34,22 +34,51 @@ export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: A export type OrderSubmissionFieldsFragment = { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null }; -export type StopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } }; +export type StopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, order?: { __typename?: 'Order', id: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, postOnly?: boolean | null, reduceOnly?: boolean | null, market: { __typename?: 'Market', id: string }, liquidityProvision?: { __typename: 'LiquidityProvision' } | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null } | null, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } }; export type StopOrdersQueryVariables = Types.Exact<{ partyId: Types.Scalars['ID']; }>; -export type StopOrdersQuery = { __typename?: 'Query', stopOrders?: { __typename?: 'StopOrderConnection', edges?: Array<{ __typename?: 'StopOrderEdge', node?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }> | null } | null }; +export type StopOrdersQuery = { __typename?: 'Query', stopOrders?: { __typename?: 'StopOrderConnection', edges?: Array<{ __typename?: 'StopOrderEdge', node?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, order?: { __typename?: 'Order', id: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, postOnly?: boolean | null, reduceOnly?: boolean | null, market: { __typename?: 'Market', id: string }, liquidityProvision?: { __typename: 'LiquidityProvision' } | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null } | null, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }> | null } | null }; export type StopOrderByIdQueryVariables = Types.Exact<{ stopOrderId: Types.Scalars['ID']; }>; -export type StopOrderByIdQuery = { __typename?: 'Query', stopOrder?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }; +export type StopOrderByIdQuery = { __typename?: 'Query', stopOrder?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, order?: { __typename?: 'Order', id: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, postOnly?: boolean | null, reduceOnly?: boolean | null, market: { __typename?: 'Market', id: string }, liquidityProvision?: { __typename: 'LiquidityProvision' } | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null } | null, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }; +export const OrderUpdateFieldsFragmentDoc = gql` + fragment OrderUpdateFields on OrderUpdate { + id + marketId + type + side + size + status + rejectionReason + price + timeInForce + remaining + expiresAt + createdAt + updatedAt + liquidityProvisionId + peggedOrder { + __typename + reference + offset + } + icebergOrder { + __typename + peakSize + minimumVisibleSize + reservedRemaining + } +} + `; export const OrderFieldsFragmentDoc = gql` fragment OrderFields on Order { id @@ -85,35 +114,6 @@ export const OrderFieldsFragmentDoc = gql` } } `; -export const OrderUpdateFieldsFragmentDoc = gql` - fragment OrderUpdateFields on OrderUpdate { - id - marketId - type - side - size - status - rejectionReason - price - timeInForce - remaining - expiresAt - createdAt - updatedAt - liquidityProvisionId - peggedOrder { - __typename - reference - offset - } - icebergOrder { - __typename - peakSize - minimumVisibleSize - reservedRemaining - } -} - `; export const OrderSubmissionFieldsFragmentDoc = gql` fragment OrderSubmissionFields on OrderSubmission { marketId @@ -144,6 +144,9 @@ export const StopOrderFieldsFragmentDoc = gql` updatedAt partyId marketId + order { + ...OrderFields + } trigger { ... on StopOrderPrice { price @@ -156,7 +159,8 @@ export const StopOrderFieldsFragmentDoc = gql` ...OrderSubmissionFields } } - ${OrderSubmissionFieldsFragmentDoc}`; + ${OrderFieldsFragmentDoc} +${OrderSubmissionFieldsFragmentDoc}`; export const OrderByIdDocument = gql` query OrderById($orderId: ID!) { orderByID(id: $orderId) { diff --git a/libs/orders/src/lib/components/order-list/order-list.tsx b/libs/orders/src/lib/components/order-list/order-list.tsx index 687c73b46..aca064bd0 100644 --- a/libs/orders/src/lib/components/order-list/order-list.tsx +++ b/libs/orders/src/lib/components/order-list/order-list.tsx @@ -296,7 +296,7 @@ export const OrderListTable = memo< )} - + { const create = useVegaTransactionStore((state) => state.create); + const [viewOrder, setViewOrder] = useState(null); const variables = { partyId }; const { data, error, reload } = useDataProvider({ @@ -53,14 +56,25 @@ export const StopOrdersManager = ({ ); return ( - + <> + + {viewOrder && ( + setViewOrder(null)} + onMarketClick={onMarketClick} + /> + )} + ); }; diff --git a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx index fa47676c5..7377a105f 100644 --- a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx +++ b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx @@ -4,6 +4,7 @@ import type { PartialDeep } from 'type-fest'; import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet'; import { MockedProvider } from '@apollo/client/testing'; +import userEvent from '@testing-library/user-event'; import { StopOrdersTable, type StopOrdersTableProps, @@ -27,6 +28,7 @@ jest.mock('@vegaprotocol/utils', () => ({ })); const defaultProps: StopOrdersTableProps = { + onView: jest.fn(), rowData: [], onCancel: jest.fn(), isReadOnly: false, @@ -104,6 +106,7 @@ const rowData = [ generateStopOrder({ id: 'stop-order-6', status: Schema.StopOrderStatus.STATUS_TRIGGERED, + order: { id: 'order-id' }, }), ]; @@ -234,4 +237,37 @@ describe('StopOrdersTable', () => { ); }); }); + + it('shows actions dropdown only for triggered stop orders', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const dropdownMenuButtons = screen.getAllByTestId('dropdown-menu'); + expect(dropdownMenuButtons).toHaveLength(1); + dropdownMenuButtons.forEach((dropdownMenuButton) => { + const id = dropdownMenuButton + .closest('[role="row"]') + ?.getAttribute('row-id'); + expect(rowData.find((row) => row.id === id)?.status).toEqual( + Schema.StopOrderStatus.STATUS_TRIGGERED + ); + }); + }); + + it('action dropdown has copy and view order actions', async () => { + const onView = jest.fn(); + const user = userEvent.setup(); + await act(async () => { + render(generateJsx({ rowData, onView })); + }); + const dropdownMenuButtons = screen.getByTestId('dropdown-menu'); + dropdownMenuButtons.click(); + await user.click(dropdownMenuButtons as HTMLButtonElement); + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems).toHaveLength(2); + expect(menuItems[0]).toHaveTextContent('Copy order ID'); + expect(menuItems[1]).toHaveTextContent('View order details'); + menuItems[1].click(); + expect(onView).toBeCalled(); + }); }); diff --git a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx index 35698c16b..9af429a0e 100644 --- a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx +++ b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx @@ -7,8 +7,15 @@ import { } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import * as Schema from '@vegaprotocol/types'; -import { ButtonLink } from '@vegaprotocol/ui-toolkit'; -import type { ForwardedRef } from 'react'; +import { + ActionsDropdown, + ButtonLink, + VegaIcon, + VegaIconNames, + DropdownMenuItem, + TradingDropdownCopyItem, + Pill, +} from '@vegaprotocol/ui-toolkit'; import { memo, useMemo } from 'react'; import { AgGridLazy as AgGrid, @@ -25,9 +32,9 @@ import type { VegaValueFormatterParams, VegaValueGetterParams, } from '@vegaprotocol/datagrid'; -import type { AgGridReact } from 'ag-grid-react'; import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; import type { ColDef } from 'ag-grid-community'; +import type { Order } from '../order-data-provider'; const defaultColDef = { resizable: true, @@ -38,219 +45,253 @@ const defaultColDef = { export type StopOrdersTableProps = TypedDataAgGrid & { onCancel: (order: StopOrder) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void; + onView: (order: Order) => void; isReadOnly: boolean; }; -export const StopOrdersTable = memo< - StopOrdersTableProps & { ref?: ForwardedRef } ->(({ onCancel, onMarketClick, ...props }: StopOrdersTableProps) => { - const showAllActions = !props.isReadOnly; - const columnDefs: ColDef[] = useMemo( - () => [ - { - headerName: t('Market'), - field: 'market.tradableInstrument.instrument.code', - cellRenderer: 'MarketNameCell', - cellRendererParams: { idPath: 'market.id', onMarketClick }, - }, - { - headerName: t('Trigger'), - field: 'trigger', - cellClass: 'font-mono text-right', - type: 'rightAligned', - sortable: false, - valueFormatter: ({ - data, - }: VegaValueFormatterParams): string => - data ? formatTrigger(data, data.market.decimalPlaces) : '', - }, - { - field: 'expiresAt', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => { - if ( - data && - value && - data?.expiryStrategy !== - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED - ) { - const expiresAt = getDateTimeFormat().format(new Date(value)); - const expiryStrategy = - data.expiryStrategy === - Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT - ? t('Submit') - : t('Cancels'); - return `${expiryStrategy} ${expiresAt}`; - } - return ''; +export const StopOrdersTable = memo( + ({ onCancel, onMarketClick, onView, ...props }: StopOrdersTableProps) => { + const showAllActions = !props.isReadOnly; + const columnDefs: ColDef[] = useMemo( + () => [ + { + headerName: t('Market'), + field: 'market.tradableInstrument.instrument.code', + cellRenderer: 'MarketNameCell', + cellRendererParams: { idPath: 'market.id', onMarketClick }, }, - }, - { - headerName: t('Size'), - field: 'submission.size', - cellClass: 'font-mono text-right', - type: 'rightAligned', - cellClassRules: { - [positiveClassNames]: ({ data }: { data: StopOrder }) => - data?.submission.size === Schema.Side.SIDE_BUY, - [negativeClassNames]: ({ data }: { data: StopOrder }) => - data?.submission.size === Schema.Side.SIDE_SELL, + { + headerName: t('Trigger'), + field: 'trigger', + cellClass: 'font-mono text-right', + type: 'rightAligned', + sortable: false, + valueFormatter: ({ + data, + }: VegaValueFormatterParams): string => + data ? formatTrigger(data, data.market.decimalPlaces) : '', }, - valueGetter: ({ data }: VegaValueGetterParams) => { - return data?.submission.size && data.market - ? toBigNum( - data.submission.size, - data.market.positionDecimalPlaces ?? 0 - ) - .multipliedBy( - data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1 + { + field: 'expiresAt', + valueFormatter: ({ + value, + data, + }: VegaValueFormatterParams) => { + if ( + data && + value && + data?.expiryStrategy !== + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED + ) { + const expiresAt = getDateTimeFormat().format(new Date(value)); + const expiryStrategy = + data.expiryStrategy === + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT + ? t('Submit') + : t('Cancels'); + return `${expiryStrategy} ${expiresAt}`; + } + return ''; + }, + }, + { + headerName: t('Size'), + field: 'submission.size', + cellClass: 'font-mono text-right', + type: 'rightAligned', + cellClassRules: { + [positiveClassNames]: ({ data }: { data: StopOrder }) => + data?.submission.size === Schema.Side.SIDE_BUY, + [negativeClassNames]: ({ data }: { data: StopOrder }) => + data?.submission.size === Schema.Side.SIDE_SELL, + }, + valueGetter: ({ data }: VegaValueGetterParams) => { + return data?.submission.size && data.market + ? toBigNum( + data.submission.size, + data.market.positionDecimalPlaces ?? 0 ) - .toNumber() - : undefined; + .multipliedBy( + data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1 + ) + .toNumber() + : undefined; + }, + valueFormatter: ({ + data, + }: VegaValueFormatterParams) => { + if (!data) { + return ''; + } + if (!data?.market || !isNumeric(data.submission.size)) { + return '-'; + } + const prefix = data + ? data.submission.side === Schema.Side.SIDE_BUY + ? '+' + : '-' + : ''; + return ( + prefix + + addDecimalsFormatNumber( + data.submission.size, + data.market.positionDecimalPlaces + ) + ); + }, }, - valueFormatter: ({ - data, - }: VegaValueFormatterParams) => { - if (!data) { - return ''; - } - if (!data?.market || !isNumeric(data.submission.size)) { - return '-'; - } - const prefix = data - ? data.submission.side === Schema.Side.SIDE_BUY - ? '+' - : '-' - : ''; - return ( - prefix + - addDecimalsFormatNumber( - data.submission.size, - data.market.positionDecimalPlaces - ) - ); + { + field: 'submission.type', + filter: SetFilter, + filterParams: { + set: Schema.OrderTypeMapping, + }, + cellRenderer: ({ + value, + }: VegaICellRendererParams) => + value ? Schema.OrderTypeMapping[value] : '', }, - }, - { - field: 'submission.type', - filter: SetFilter, - filterParams: { - set: Schema.OrderTypeMapping, + { + field: 'status', + filter: SetFilter, + filterParams: { + set: Schema.StopOrderStatusMapping, + }, + valueFormatter: ({ + value, + }: VegaValueFormatterParams) => { + return value ? Schema.StopOrderStatusMapping[value] : ''; + }, + cellRenderer: ({ + valueFormatted, + data, + }: { + valueFormatted: string; + data: StopOrder; + }) => ( + <> + + {valueFormatted} + + {data.ocoLinkId && ( + + OCO + + )} + + ), }, - cellRenderer: ({ - value, - }: VegaICellRendererParams) => - value ? Schema.OrderTypeMapping[value] : '', - }, - { - field: 'status', - filter: SetFilter, - filterParams: { - set: Schema.StopOrderStatusMapping, + { + field: 'submission.price', + type: 'rightAligned', + cellClass: 'font-mono text-right', + valueFormatter: ({ + value, + data, + }: VegaValueFormatterParams) => { + if (!data) { + return ''; + } + if ( + !data?.market || + data.submission.type === Schema.OrderType.TYPE_MARKET || + !isNumeric(value) + ) { + return '-'; + } + return addDecimalsFormatNumber(value, data.market.decimalPlaces); + }, }, - valueFormatter: ({ - value, - }: VegaValueFormatterParams) => { - return value ? Schema.StopOrderStatusMapping[value] : ''; + { + field: 'submission.timeInForce', + filter: SetFilter, + filterParams: { + set: Schema.OrderTimeInForceMapping, + }, + valueFormatter: ({ + value, + }: VegaValueFormatterParams) => { + return value ? Schema.OrderTimeInForceCode[value] : ''; + }, }, - cellRenderer: ({ - valueFormatted, - data, - }: { - valueFormatted: string; - data: StopOrder; - }) => ( - {valueFormatted} - ), - }, - { - field: 'submission.price', - type: 'rightAligned', - cellClass: 'font-mono text-right', - valueFormatter: ({ - value, - data, - }: VegaValueFormatterParams) => { - if (!data) { - return ''; - } - if ( - !data?.market || - data.submission.type === Schema.OrderType.TYPE_MARKET || - !isNumeric(value) - ) { - return '-'; - } - return addDecimalsFormatNumber(value, data.market.decimalPlaces); + { + field: 'updatedAt', + filter: DateRangeFilter, + valueGetter: ({ data }: VegaValueGetterParams) => + data?.updatedAt || data?.createdAt, + cellRenderer: ({ + data, + }: VegaICellRendererParams) => { + if (!data) { + return undefined; + } + const value = data.updatedAt || data.createdAt; + return ( + + {value ? getDateTimeFormat().format(new Date(value)) : '-'} + + ); + }, }, - }, - { - field: 'submission.timeInForce', - filter: SetFilter, - filterParams: { - set: Schema.OrderTimeInForceMapping, - }, - valueFormatter: ({ - value, - }: VegaValueFormatterParams) => { - return value ? Schema.OrderTimeInForceCode[value] : ''; - }, - }, - { - field: 'updatedAt', - filter: DateRangeFilter, - valueGetter: ({ data }: VegaValueGetterParams) => - data?.updatedAt || data?.createdAt, - cellRenderer: ({ - data, - }: VegaICellRendererParams) => { - if (!data) { - return undefined; - } - const value = data.updatedAt || data.createdAt; - return ( - - {value ? getDateTimeFormat().format(new Date(value)) : '-'} - - ); - }, - }, - { - colId: 'actions', - ...COL_DEFS.actions, - minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, - maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, - cellRenderer: ({ data }: { data?: StopOrder }) => { - if (!data) return null; + { + colId: 'actions', + ...COL_DEFS.actions, + minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, + maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, + cellRenderer: ({ data }: { data?: StopOrder }) => { + if (!data) return null; - return ( -
    - {data.status === Schema.StopOrderStatus.STATUS_PENDING && - !props.isReadOnly && ( - onCancel(data)} - > - {t('Cancel')} - - )} -
    - ); + return ( +
    + {data.status === Schema.StopOrderStatus.STATUS_PENDING && + !props.isReadOnly && ( + onCancel(data)} + > + {t('Cancel')} + + )} + {data.status === Schema.StopOrderStatus.STATUS_TRIGGERED && + data.order && ( + + + + data.order && + onView({ ...data.order, market: data.market }) + } + > + + {t('View order details')} + + + )} +
    + ); + }, }, - }, - ], - [onCancel, onMarketClick, props.isReadOnly, showAllActions] - ); + ], + [onCancel, onMarketClick, onView, props.isReadOnly, showAllActions] + ); - return ( - data.id} - components={{ MarketNameCell }} - {...props} - /> - ); -}); + return ( + data.id} + components={{ MarketNameCell }} + {...props} + /> + ); + } +); diff --git a/libs/positions/src/lib/positions-data-providers.ts b/libs/positions/src/lib/positions-data-providers.ts index d5c533b83..fb0ad4624 100644 --- a/libs/positions/src/lib/positions-data-providers.ts +++ b/libs/positions/src/lib/positions-data-providers.ts @@ -181,7 +181,7 @@ const getSubscriptionVariables = ( ): PositionsSubscriptionSubscriptionVariables[] => ([] as string[]).concat(variables.partyIds).map((partyId) => ({ partyId })); -const positionsDataProvider = makeDataProvider< +export const positionsDataProvider = makeDataProvider< PositionsQuery, PositionFieldsFragment[], PositionsSubscriptionSubscription, diff --git a/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.spec.ts b/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.spec.ts index 34343a748..13f26e68c 100644 --- a/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.spec.ts +++ b/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.spec.ts @@ -1,5 +1,9 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { useTimeToUpgrade } from './use-time-to-upgrade'; +import { + ERR_NO_TIME_UNITS, + parseDuration, + useTimeToUpgrade, +} from './use-time-to-upgrade'; jest.mock('./__generated__/BlockStatistics', () => ({ ...jest.requireActual('./__generated__/BlockStatistics'), @@ -8,7 +12,7 @@ jest.mock('./__generated__/BlockStatistics', () => ({ data: { statistics: { blockHeight: 1, - blockDuration: 500, + blockDuration: '500ms', }, }, }; @@ -30,3 +34,25 @@ describe('useTimeToUpgrade', () => { }); }); }); + +describe('parseDuration', () => { + it.each([ + ['1000000ns', 1], + ['1000Âĩs', 1], + ['1ms', 1], + ['1s', 1000], + ['1m', 60 * 1000], + ['1h', 60 * 60 * 1000], + // below test cases are from vega + ['3.3s', 3300], + ['4m5s', 4 * 60 * 1000 + 5 * 1000], + ['4m5.001s', 4 * 60 * 1000 + 5001], + ['5h6m7.001s', 5 * 60 * 60 * 1000 + 6 * 60 * 1000 + 7001], + ['8m0.000000001s', 8 * 60 * 1000 + 1 / 1000000], + ])('parses %s to %d milliseconds', (input, output) => { + expect(parseDuration(input)).toEqual(output); + }); + it('throws an error when given corrupted data', () => { + expect(() => parseDuration('blah')).toThrow(ERR_NO_TIME_UNITS); + }); +}); diff --git a/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.ts b/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.ts index 7e9c232ae..98211d900 100644 --- a/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.ts +++ b/libs/proposals/src/lib/protocol-upgrade-proposals/use-time-to-upgrade.ts @@ -7,6 +7,52 @@ const DEFAULT_POLLS = 10; const INTERVAL = 1000; const durations = [] as number[]; +export const ERR_NO_TIME_UNITS = new Error( + 'could not parse block duration value - no time units detected' +); + +/** + * Parses block duration value and output a number of milliseconds. + * @param input The block duration input from the API, e.g. 4m5.001s + * @returns A number of milliseconds + */ +export const parseDuration = (input: string) => { + // h -> 60*60*1000 + // m -> 60*1000 + // s -> 1000 + // ms -> 1 + // Âĩs -> 1/1000 + // ns -> 1/1000000 + let H = 0; + let M = 0; + let S = 0; + const lessThanSecond = /^[0-9.]+[nÂĩm]*s$/gu.test(input); + const exp = /(?[0-9.]+h)?(?[0-9.]+m)?(?[0-9.]+s)?/gu; + const m = exp.exec(input); + + const hours = m?.groups?.['hours']; + const minutes = m?.groups?.['minutes']; + const seconds = lessThanSecond ? input : m?.groups?.['seconds']; + if (!lessThanSecond && !hours && !minutes && !seconds) { + throw ERR_NO_TIME_UNITS; + } + + if (seconds) { + S = parseFloat(seconds); + if (seconds.includes('ns')) S /= 1000 * 1000; + else if (seconds.includes('Âĩs')) S /= 1000; + else if (seconds.includes('ms')) S *= 1; + else if (seconds.includes('s')) S *= 1000; + } + if (minutes && !lessThanSecond) { + M = parseFloat(minutes) * 60 * 1000; + } + if (hours && !lessThanSecond) { + H = parseFloat(hours) * 60 * 60 * 1000; + } + return H + M + S; +}; + const useAverageBlockDuration = (polls = DEFAULT_POLLS) => { const [avg, setAvg] = useState(undefined); const { data, startPolling, stopPolling, error } = useBlockStatisticsQuery({ @@ -28,7 +74,11 @@ const useAverageBlockDuration = (polls = DEFAULT_POLLS) => { useEffect(() => { if (durations.length < polls && data) { - durations.push(parseFloat(data.statistics.blockDuration)); + try { + durations.push(parseDuration(data.statistics.blockDuration)); // ms + } catch (err) { + // NOOP - do not add unparsed value to AVG + } } if (durations.length === polls) { const averageBlockDuration = sum(durations) / durations.length; // ms diff --git a/libs/tailwindcss-config/src/theme.js b/libs/tailwindcss-config/src/theme.js index 111b0bf0f..28b041988 100644 --- a/libs/tailwindcss-config/src/theme.js +++ b/libs/tailwindcss-config/src/theme.js @@ -177,7 +177,21 @@ module.exports = { success: '#00F780', }, fontFamily: { - mono: ['Roboto Mono', 'monospace'], + mono: [ + 'ui-monospace', + 'Menlo', + 'Monaco', + 'Cascadia Mono', + 'Segoe UI Mono', + 'Roboto Mono', + 'Oxygen Mono', + 'Ubuntu Monospace', + 'Source Code Pro', + 'Fira Mono', + 'Droid Sans Mono', + 'Courier New', + 'monospace', + ], sans: [ '"Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', ], diff --git a/libs/types/src/__generated__/types.ts b/libs/types/src/__generated__/types.ts index 28441bfcf..3ea7ba599 100644 --- a/libs/types/src/__generated__/types.ts +++ b/libs/types/src/__generated__/types.ts @@ -4414,6 +4414,8 @@ export type StopOrderFilter = { dateRange?: InputMaybe; /** Zero or more expiry strategies to filter by */ expiryStrategy?: InputMaybe>; + /** Filter for live stop orders only */ + liveOnly?: InputMaybe; /** Zero or more market IDs to filter by */ markets?: InputMaybe>; /** Zero or more party IDs to filter by */ diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx new file mode 100644 index 000000000..e3976cf43 --- /dev/null +++ b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-metamask.tsx @@ -0,0 +1,102 @@ +export const IconMetaMask = ({ size = 16 }: { size: number }) => ( + + + + + + + + + + + + + + + + +); diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts index 60ee1f1ad..e8069b90f 100644 --- a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts +++ b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts @@ -1,8 +1,8 @@ import { IconArrowDown } from './svg-icons/icon-arrow-down'; import { IconArrowLeft } from './svg-icons/icon-arrow-left'; -import { IconArrowUp } from './svg-icons/icon-arrow-up'; import { IconArrowRight } from './svg-icons/icon-arrow-right'; import { IconArrowTopRight } from './svg-icons/icon-arrow-top-right'; +import { IconArrowUp } from './svg-icons/icon-arrow-up'; import { IconBreakdown } from './svg-icons/icon-breakdown'; import { IconBullet } from './svg-icons/icon-bullet'; import { IconChevronDown } from './svg-icons/icon-chevron-down'; @@ -20,28 +20,29 @@ import { IconGlobe } from './svg-icons/icon-globe'; import { IconInfo } from './svg-icons/icon-info'; import { IconKebab } from './svg-icons/icon-kebab'; import { IconLinkedIn } from './svg-icons/icon-linkedin'; +import { IconMetaMask } from './svg-icons/icon-metamask'; import { IconMinus } from './svg-icons/icon-minus'; import { IconMoon } from './svg-icons/icon-moon'; import { IconOpenExternal } from './svg-icons/icon-open-external'; -import { IconQuestionMark } from './svg-icons/icon-question-mark'; import { IconPlus } from './svg-icons/icon-plus'; +import { IconQuestionMark } from './svg-icons/icon-question-mark'; +import { IconSearch } from './svg-icons/icon-search'; import { IconStar } from './svg-icons/icon-star'; import { IconTick } from './svg-icons/icon-tick'; import { IconTicket } from './svg-icons/icon-ticket'; import { IconTransfer } from './svg-icons/icon-transfer'; -import { IconTrendUp } from './svg-icons/icon-trend-up'; import { IconTrendDown } from './svg-icons/icon-trend-down'; +import { IconTrendUp } from './svg-icons/icon-trend-up'; import { IconTwitter } from './svg-icons/icon-twitter'; import { IconVote } from './svg-icons/icon-vote'; import { IconWithdraw } from './svg-icons/icon-withdraw'; -import { IconSearch } from './svg-icons/icon-search'; export enum VegaIconNames { ARROW_DOWN = 'arrow-down', ARROW_LEFT = 'arrow-left', - ARROW_UP = 'arrow-up', ARROW_RIGHT = 'arrow-right', ARROW_TOP_RIGHT = 'arrow-top-right', + ARROW_UP = 'arrow-up', BREAKDOWN = 'breakdown', BULLET = 'bullet', CHEVRON_DOWN = 'chevron-down', @@ -59,18 +60,19 @@ export enum VegaIconNames { INFO = 'info', KEBAB = 'kebab', LINKEDIN = 'linkedin', + METAMASK = 'metamask', MINUS = 'minus', MOON = 'moon', OPEN_EXTERNAL = 'open-external', - QUESTION_MARK = 'question-mark', PLUS = 'plus', + QUESTION_MARK = 'question-mark', SEARCH = 'search', STAR = 'star', TICK = 'tick', TICKET = 'ticket', TRANSFER = 'transfer', - TREND_UP = 'trend-up', TREND_DOWN = 'trend-down', + TREND_UP = 'trend-up', TWITTER = 'twitter', VOTE = 'vote', WITHDRAW = 'withdraw', @@ -82,38 +84,39 @@ export const VegaIconNameMap: Record< > = { 'arrow-down': IconArrowDown, 'arrow-left': IconArrowLeft, - 'arrow-up': IconArrowUp, 'arrow-right': IconArrowRight, 'arrow-top-right': IconArrowTopRight, - breakdown: IconBreakdown, - bullet: IconBullet, + 'arrow-up': IconArrowUp, 'chevron-down': IconChevronDown, 'chevron-left': IconChevronLeft, 'chevron-up': IconChevronUp, + 'exclaimation-mark': IconExclaimationMark, + 'open-external': IconOpenExternal, + 'question-mark': IconQuestionMark, + 'trend-down': IconTrendDown, + 'trend-up': IconTrendUp, + breakdown: IconBreakdown, + bullet: IconBullet, cog: IconCog, copy: IconCopy, cross: IconCross, deposit: IconDeposit, edit: IconEdit, - 'exclaimation-mark': IconExclaimationMark, eye: IconEye, forum: IconForum, globe: IconGlobe, info: IconInfo, kebab: IconKebab, linkedin: IconLinkedIn, + metamask: IconMetaMask, minus: IconMinus, moon: IconMoon, - 'open-external': IconOpenExternal, plus: IconPlus, - 'question-mark': IconQuestionMark, search: IconSearch, star: IconStar, tick: IconTick, ticket: IconTicket, transfer: IconTransfer, - 'trend-up': IconTrendUp, - 'trend-down': IconTrendDown, twitter: IconTwitter, vote: IconVote, withdraw: IconWithdraw, diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx index cc48b741f..3a240d6ab 100644 --- a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx +++ b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx @@ -5,7 +5,7 @@ import { VegaIconNameMap } from './vega-icon-record'; export interface VegaIconProps { name: VegaIconNames; - size?: 8 | 10 | 12 | 13 | 14 | 16 | 20 | 24 | 32; + size?: 8 | 10 | 12 | 13 | 14 | 16 | 18 | 20 | 24 | 32; } export const VegaIcon = ({ size = 16, name }: VegaIconProps) => { diff --git a/libs/ui-toolkit/src/components/index.ts b/libs/ui-toolkit/src/components/index.ts index 2b76f30bd..d173012d0 100644 --- a/libs/ui-toolkit/src/components/index.ts +++ b/libs/ui-toolkit/src/components/index.ts @@ -34,6 +34,7 @@ export * from './progress-bar'; export * from './radio-group'; export * from './rounded-wrapper'; export * from './select'; +export * from './show-more'; export * from './simple-grid'; export * from './slider'; export * from './sparkline'; diff --git a/libs/ui-toolkit/src/components/show-more/index.ts b/libs/ui-toolkit/src/components/show-more/index.ts new file mode 100644 index 000000000..29487baa3 --- /dev/null +++ b/libs/ui-toolkit/src/components/show-more/index.ts @@ -0,0 +1 @@ +export * from './show-more'; diff --git a/libs/ui-toolkit/src/components/show-more/show-more.spec.tsx b/libs/ui-toolkit/src/components/show-more/show-more.spec.tsx new file mode 100644 index 000000000..cc65507ea --- /dev/null +++ b/libs/ui-toolkit/src/components/show-more/show-more.spec.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react'; +import { ShowMore } from './show-more'; + +describe('Button', () => { + it('should render successfully', () => { + const { baseElement } = render(test); + expect(baseElement).toBeTruthy(); + }); +}); diff --git a/libs/ui-toolkit/src/components/show-more/show-more.stories.tsx b/libs/ui-toolkit/src/components/show-more/show-more.stories.tsx new file mode 100644 index 000000000..cfdcd6dff --- /dev/null +++ b/libs/ui-toolkit/src/components/show-more/show-more.stories.tsx @@ -0,0 +1,50 @@ +import type { Story, Meta } from '@storybook/react'; +import { ShowMore } from './show-more'; + +export default { + component: ShowMore, + title: 'ShowMore', +} as Meta; + +const Template: Story = (args) => ( + +

    + Spaceflight will never tolerate carelessness, incapacity, and neglect. + Somewhere, somehow, we screwed up. It could have been in design, build, or + test. Whatever it was, we should have caught it. We were too gung ho about + the schedule and we locked out all of the problems we saw each day in our + work. “Every element of the program was in trouble and so were we. The + simulators were not working, Mission Control was behind in virtually every + area, and the flight and test procedures changed daily. Nothing we did had + any shelf life. Not one of us stood up and said, ‘Dammit, stop!’ I don’t + know what Thompson’s committee will find as the cause, but I know what I + find. We are the cause! We were not ready! We did not do our job. We were + rolling the dice, hoping that things would come together by launch day, + when in our hearts we knew it would take a miracle. We were pushing the + schedule and betting that the Cape would slip before we did. “From this + day forward, Flight Control will be known by two words: ‘Tough’ and + ‘Competent.’ Tough means we are forever accountable for what we do or what + we fail to do. We will never again compromise our responsibilities. Every + time we walk into Mission Control we will know what we stand for. + Competent means we will never take anything for granted. We will never be + found short in our knowledge and in our skills. Mission Control will be + perfect. When you leave this meeting today you will go to your office and + the first thing you will do there is to write ‘Tough and Competent’ on + your blackboards. It will never be erased. Each day when you enter the + room these words will remind you of the price paid by Grissom, White, and + Chaffee. These words are the price of admission to the ranks of Mission + Control. +

    +
    +); + +export const Default = Template.bind({}); +export const CustomMaxHeight = Template.bind({}); +CustomMaxHeight.args = { + closedMaxHeightPx: 50, +}; + +export const CustomOverlayColour = Template.bind({}); +CustomOverlayColour.args = { + overlayColourOverrides: 'to-yellow-400', +}; diff --git a/libs/ui-toolkit/src/components/show-more/show-more.tsx b/libs/ui-toolkit/src/components/show-more/show-more.tsx new file mode 100644 index 000000000..656a4582f --- /dev/null +++ b/libs/ui-toolkit/src/components/show-more/show-more.tsx @@ -0,0 +1,82 @@ +import classNames from 'classnames'; +import { useRef, useState, useEffect } from 'react'; +import { t } from '@vegaprotocol/i18n'; +import { Button } from '../button'; +import type { ReactNode } from 'react'; + +type ShowMoreProps = { + children: ReactNode; + closedMaxHeightPx?: number; + overlayColourOverrides?: string; +}; + +export const ShowMore = ({ + children, + closedMaxHeightPx = 125, + overlayColourOverrides, +}: ShowMoreProps) => { + const containerRef = useRef(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + const checkHeight = () => { + const container = containerRef.current; + if (container) { + container.scrollHeight < closedMaxHeightPx + ? setExpanded(true) + : setExpanded(false); + } + }; + + checkHeight(); + + window.addEventListener('resize', checkHeight); + + return () => { + window.removeEventListener('resize', checkHeight); + }; + }, [closedMaxHeightPx]); + + const containerClasses = classNames( + 'overflow-hidden transition-all ease-in-out duration-300', + { + 'max-h-none': expanded, + } + ); + + const overlayClasses = classNames( + `absolute w-full h-16 bottom-0 left-0 transition-opacity duration-300 bg-gradient-to-b from-transparent ${ + overlayColourOverrides ? overlayColourOverrides : 'to-white dark:to-black' + }`, + { + hidden: expanded, + } + ); + + return ( + <> +
    +
    + {children} +
    +
    +
    + + {!expanded && ( +
    + +
    + )} + + ); +}; diff --git a/libs/ui-toolkit/src/components/trading-input/input.tsx b/libs/ui-toolkit/src/components/trading-input/input.tsx index 7bef3d2cc..20345f08a 100644 --- a/libs/ui-toolkit/src/components/trading-input/input.tsx +++ b/libs/ui-toolkit/src/components/trading-input/input.tsx @@ -80,13 +80,14 @@ const getAffixElement = ({ appendIconName, appendIconDescription, }: Pick) => { - const position = prependIconName || prependElement ? 'pre' : 'post'; - const className = classNames( - ['fill-black dark:fill-white', 'absolute', 'z-10'], + 'absolute z-10 top-0 bottom-0 flex items-center', { - 'left-3': position === 'pre', - 'right-3': position === 'post', + 'fill-black dark:fill-white': prependIconName || appendIconName, + 'left-3': prependIconName, + 'right-3': appendIconName, + 'left-1': prependElement, + 'right-1': appendElement, } ); @@ -161,7 +162,7 @@ export const TradingInput = forwardRef( if (element) { return ( -
    +
    {hasPrepended && element} {input} {hasAppended && element} diff --git a/libs/utils/src/lib/validate/common.ts b/libs/utils/src/lib/validate/common.ts index 9590f4cb3..4fb3200d2 100644 --- a/libs/utils/src/lib/validate/common.ts +++ b/libs/utils/src/lib/validate/common.ts @@ -15,8 +15,9 @@ export const ethereumAddress = (value: string) => { return true; }; +export const VEGA_ID_REGEX = /^[A-Fa-f0-9]{64}$/i; export const vegaPublicKey = (value: string) => { - if (!/^[A-Fa-f0-9]{64}$/i.test(value)) { + if (!VEGA_ID_REGEX.test(value)) { return t('Invalid Vega key'); } return true; diff --git a/libs/wallet/package.json b/libs/wallet/package.json index 53b5de294..cbe3184e6 100644 --- a/libs/wallet/package.json +++ b/libs/wallet/package.json @@ -1,4 +1,8 @@ { "name": "@vegaprotocol/wallet", - "version": "0.0.1" + "version": "0.0.1", + "peerDependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + } } diff --git a/libs/wallet/project.json b/libs/wallet/project.json index 22bb3711f..9c5884f9d 100644 --- a/libs/wallet/project.json +++ b/libs/wallet/project.json @@ -13,9 +13,10 @@ "tsConfig": "libs/wallet/tsconfig.lib.json", "project": "libs/wallet/package.json", "entryFile": "libs/wallet/src/index.ts", - "external": ["react/jsx-runtime"], + "external": ["react", "react-dom", "react/jsx-runtime"], "rollupConfig": "@nx/react/plugins/bundle-rollup", "compiler": "babel", + "format": ["esm", "cjs"], "assets": [ { "glob": "libs/wallet/README.md", diff --git a/libs/wallet/src/connect-dialog/chrome-icon.tsx b/libs/wallet/src/connect-dialog/chrome-icon.tsx new file mode 100644 index 000000000..f51ec69c5 --- /dev/null +++ b/libs/wallet/src/connect-dialog/chrome-icon.tsx @@ -0,0 +1,77 @@ +export const ChromeIcon = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/libs/wallet/src/connect-dialog/connect-dialog-elements.tsx b/libs/wallet/src/connect-dialog/connect-dialog-elements.tsx index 7f3ebd3cf..224e034fd 100644 --- a/libs/wallet/src/connect-dialog/connect-dialog-elements.tsx +++ b/libs/wallet/src/connect-dialog/connect-dialog-elements.tsx @@ -1,4 +1,3 @@ -import { ExternalLinks, useEnvironment } from '@vegaprotocol/environment'; import { t } from '@vegaprotocol/i18n'; import { ExternalLink, @@ -7,12 +6,15 @@ import { } from '@vegaprotocol/ui-toolkit'; import classNames from 'classnames'; import type { ReactNode } from 'react'; +import { MozillaIcon } from './mozilla-icon'; +import { ChromeIcon } from './chrome-icon'; +import { useVegaWallet } from '../use-vega-wallet'; export const ConnectDialogTitle = ({ children }: { children: ReactNode }) => { return (

    {children}

    @@ -24,6 +26,7 @@ export const ConnectDialogContent = ({ children }: { children: ReactNode }) => { }; export const ConnectDialogFooter = () => { + const { links } = useVegaWallet(); const wrapperClasses = classNames( 'flex justify-center gap-4 mt-4', 'px-4 md:px-8 pt-4 md:pt-6', @@ -32,335 +35,37 @@ export const ConnectDialogFooter = () => { ); return (
    - + {t('About the Vega wallet')}{' '} - {ExternalLinks.VEGA_WALLET_BROWSER_LIST && ( - <> - {' | '} - - {t('Supported browsers')}{' '} - - - - )} + {' | '} + + {t('Supported browsers')}{' '} + +
    ); }; -export const ChromeIcon = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const MozillaIcon = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const BrowserIcon = () => { - const { MOZILLA_EXTENSION_URL, CHROME_EXTENSION_URL } = useEnvironment(); +export const BrowserIcon = ({ + chromeExtensionUrl, + mozillaExtensionUrl, +}: { + chromeExtensionUrl: string; + mozillaExtensionUrl: string; +}) => { const isItChrome = window.navigator.userAgent.includes('Chrome'); const isItMozilla = window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1; return ( -
    +
    {!isItChrome && !isItMozilla ? ( <> - + {' '} - + diff --git a/libs/wallet/src/connect-dialog/connect-dialog.spec.tsx b/libs/wallet/src/connect-dialog/connect-dialog.spec.tsx index 700d4a46b..68eb88b1c 100644 --- a/libs/wallet/src/connect-dialog/connect-dialog.spec.tsx +++ b/libs/wallet/src/connect-dialog/connect-dialog.spec.tsx @@ -1,12 +1,10 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import type { MockedResponse } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing'; +import type { VegaWalletConfig } from '../provider'; import { VegaWalletProvider } from '../provider'; -import { - VegaConnectDialog, - CLOSE_DELAY, - useVegaWalletDialogStore, -} from './connect-dialog'; +import { VegaConnectDialog, CLOSE_DELAY } from './connect-dialog'; +import { useVegaWalletDialogStore } from './vega-wallet-dialog-store'; import type { VegaConnectDialogProps } from '..'; import { ClientErrors, @@ -15,7 +13,6 @@ import { ViewConnector, WalletError, } from '../connectors'; -import { useEnvironment } from '@vegaprotocol/environment'; import type { ChainIdQuery } from './__generated__/ChainId'; import { ChainIdDocument } from './__generated__/ChainId'; import { @@ -28,26 +25,14 @@ import { const mockUpdateDialogOpen = jest.fn(); const mockCloseVegaDialog = jest.fn(); -jest.mock('@vegaprotocol/environment'); let mockIsDesktopRunning = true; + jest.mock('../use-is-wallet-service-running', () => ({ useIsWalletServiceRunning: jest .fn() .mockImplementation(() => mockIsDesktopRunning), })); -// @ts-ignore ignore mock implementation -useEnvironment.mockImplementation(() => ({ - VEGA_ENV: 'TESTNET', - VEGA_URL: 'https://vega-node.url', - VEGA_NETWORKS: JSON.stringify({}), - VEGA_WALLET_URL: mockVegaWalletUrl, - GIT_BRANCH: 'test', - GIT_COMMIT_HASH: 'abcdef', - GIT_ORIGIN_URL: 'https://github.com/test/repo', - HOSTED_WALLET_URL: mockHostedWalletUrl, -})); - let defaultProps: VegaConnectDialogProps; const INITIAL_KEY = 'some-key'; @@ -59,7 +44,9 @@ const connectors = { jsonRpc, view, injected, + snap: undefined, }; + beforeEach(() => { jest.clearAllMocks(); defaultProps = { @@ -73,12 +60,24 @@ beforeEach(() => { }); }); -const mockVegaWalletUrl = 'http://mock.wallet.com'; -const mockHostedWalletUrl = 'http://mock.hosted.com'; - const mockChainId = 'chain-id'; -function generateJSX(props?: Partial) { +const defaultConfig: VegaWalletConfig = { + network: 'TESTNET', + vegaUrl: 'https://vega.xyz', + vegaWalletServiceUrl: 'https://vegaservice.xyz', + links: { + explorer: 'explorer-link', + concepts: 'concepts-link', + chromeExtensionUrl: 'chrome-link', + mozillaExtensionUrl: 'mozilla-link', + }, +}; + +function generateJSX( + props?: Partial, + config?: Partial +) { const chainIdMock: MockedResponse = { request: { query: ChainIdDocument, @@ -93,7 +92,7 @@ function generateJSX(props?: Partial) { }; return ( - + diff --git a/libs/wallet/src/connect-dialog/connect-dialog.tsx b/libs/wallet/src/connect-dialog/connect-dialog.tsx index 8714e5729..7aa5c32e4 100644 --- a/libs/wallet/src/connect-dialog/connect-dialog.tsx +++ b/libs/wallet/src/connect-dialog/connect-dialog.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import { create } from 'zustand'; import { Dialog, Intent, @@ -14,15 +13,17 @@ import type { ReactNode } from 'react'; import { useCallback, useState } from 'react'; import type { WalletClientError } from '@vegaprotocol/wallet-client'; import { t } from '@vegaprotocol/i18n'; -import type { VegaConnector } from '../connectors'; +import type { Connectors, VegaConnector } from '../connectors'; import { + DEFAULT_SNAP_ID, InjectedConnector, JsonRpcConnector, + SnapConnector, ViewConnector, + requestSnap, } from '../connectors'; import { JsonRpcConnectorForm } from './json-rpc-connector-form'; import { ViewConnectorForm } from './view-connector-form'; -import { useEnvironment } from '@vegaprotocol/environment'; import { BrowserIcon, ConnectDialogContent, @@ -38,33 +39,18 @@ import { useVegaWallet } from '../use-vega-wallet'; import { InjectedConnectorForm } from './injected-connector-form'; import { isBrowserWalletInstalled } from '../utils'; import { useIsWalletServiceRunning } from '../use-is-wallet-service-running'; +import { useIsSnapRunning } from '../use-is-snap-running'; +import { useVegaWalletDialogStore } from './vega-wallet-dialog-store'; export const CLOSE_DELAY = 1700; -type Connectors = { [key: string]: VegaConnector }; -export type WalletType = 'injected' | 'jsonRpc' | 'view'; + +export type WalletType = 'injected' | 'jsonRpc' | 'view' | 'snap'; export interface VegaConnectDialogProps { connectors: Connectors; riskMessage?: ReactNode; } -export interface VegaWalletDialogStore { - vegaWalletDialogOpen: boolean; - updateVegaWalletDialog: (open: boolean) => void; - openVegaWalletDialog: () => void; - closeVegaWalletDialog: () => void; -} - -export const useVegaWalletDialogStore = create()( - (set) => ({ - vegaWalletDialogOpen: false, - updateVegaWalletDialog: (open: boolean) => - set({ vegaWalletDialogOpen: open }), - openVegaWalletDialog: () => set({ vegaWalletDialogOpen: true }), - closeVegaWalletDialog: () => set({ vegaWalletDialogOpen: false }), - }) -); - export const VegaConnectDialog = ({ connectors, riskMessage, @@ -119,12 +105,12 @@ const ConnectDialogContainer = ({ appChainId: string; riskMessage?: ReactNode; }) => { - const { VEGA_WALLET_URL } = useEnvironment(); + const { vegaUrl, vegaWalletServiceUrl } = useVegaWallet(); const closeDialog = useVegaWalletDialogStore( (store) => store.closeVegaWalletDialog ); const [selectedConnector, setSelectedConnector] = useState(); - const [walletUrl, setWalletUrl] = useState(VEGA_WALLET_URL || ''); + const [walletUrl, setWalletUrl] = useState(vegaWalletServiceUrl); const reset = useCallback(() => { setSelectedConnector(undefined); @@ -143,7 +129,6 @@ const ConnectDialogContainer = ({ const handleSelect = (type: WalletType) => { const connector = connectors[type]; - connector.url = walletUrl; if (!connector) { // we should never get here unless connectors are not configured correctly @@ -155,17 +140,29 @@ const ConnectDialogContainer = ({ // Immediately connect on selection if jsonRpc is selected, we can't do this // for rest because we need to show an authentication form if (connector instanceof JsonRpcConnector) { + connector.url = walletUrl; jsonRpcConnect(connector, appChainId); } else if (connector instanceof InjectedConnector) { injectedConnect(connector, appChainId); + } else if (connector instanceof SnapConnector) { + // Set the nodeAddress to send tx's to, normally this is handled by + // the vega wallet + connector.nodeAddress = new URL(vegaUrl).origin; + injectedConnect(connector, appChainId); } }; + const isDesktopWalletRunning = useIsWalletServiceRunning( walletUrl, - connectors, + connectors['jsonRpc'], appChainId ); + const isSnapRunning = useIsSnapRunning( + DEFAULT_SNAP_ID, + Boolean(connectors['snap']) + ); + return ( <> @@ -181,10 +178,12 @@ const ConnectDialogContainer = ({ /> ) : ( )} @@ -194,26 +193,34 @@ const ConnectDialogContainer = ({ }; const ConnectorList = ({ + connectors, onSelect, walletUrl, setWalletUrl, isDesktopWalletRunning, + isSnapRunning, }: { + connectors: Connectors; onSelect: (type: WalletType) => void; walletUrl: string; setWalletUrl: (value: string) => void; isDesktopWalletRunning: boolean | null; + isSnapRunning: boolean | null; }) => { + const { pubKey, links } = useVegaWallet(); const title = isBrowserWalletInstalled() ? t('Connect Vega wallet') : t('Get a Vega wallet'); const extendedText = ( <> -
    +
    {t('Connect')}
    - + ); @@ -234,14 +241,57 @@ const ConnectorList = ({ onClick={() => onSelect('injected')} /> ) : ( - + )}
    + {connectors['snap'] !== undefined ? ( +
    + {isSnapRunning ? ( + +
    + {t('Connect via Vega MetaMask Snap')} +
    +
    + +
    + + } + onClick={() => { + onSelect('snap'); + }} + /> + ) : ( + +
    + {t('Install Vega MetaMask Snap')} +
    +
    + +
    + + } + onClick={() => { + requestSnap(DEFAULT_SNAP_ID); + }} + /> + )} +
    + ) : null}
    onSelect('view')} + disabled={Boolean(pubKey)} />
    @@ -280,7 +330,10 @@ const SelectedForm = ({ onConnect: () => void; riskMessage?: ReactNode; }) => { - if (connector instanceof InjectedConnector) { + if ( + connector instanceof InjectedConnector || + connector instanceof SnapConnector + ) { return ( { - const { MOZILLA_EXTENSION_URL, CHROME_EXTENSION_URL } = useEnvironment(); +export const GetWalletButton = ({ + chromeExtensionUrl, + mozillaExtensionUrl, + className, +}: { + chromeExtensionUrl?: string; + mozillaExtensionUrl?: string; + className?: string; +}) => { const isItChrome = window.navigator.userAgent.includes('Chrome'); const isItMozilla = window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + const onClick = () => { if (isItMozilla) { - window.open(MOZILLA_EXTENSION_URL, '_blank'); + window.open(mozillaExtensionUrl, '_blank'); return; } if (isItChrome) { - window.open(CHROME_EXTENSION_URL, '_blank'); + window.open(chromeExtensionUrl, '_blank'); } }; const buttonContent = ( <> -
    +
    {t('Get the Vega Wallet')} ALPHA
    - + {chromeExtensionUrl && mozillaExtensionUrl && ( + + )} ); return !isItChrome && !isItMozilla ? (
    {buttonContent} @@ -360,7 +429,7 @@ const GetWallet = () => { onClick={onClick} intent={Intent.Info} data-testid="get-wallet-button" - className="relative" + className={classNames('relative', className)} size="small" fill > @@ -393,7 +462,7 @@ const ConnectionOption = ({ icon={icon} fill > - {text} + {text} ); }; @@ -409,6 +478,7 @@ const CustomUrlInput = ({ isDesktopWalletRunning: boolean | null; onSelect: (type: WalletType) => void; }) => { + const { pubKey } = useVegaWallet(); const [urlInputExpanded, setUrlInputExpanded] = useState(false); return urlInputExpanded ? ( <> @@ -433,7 +503,7 @@ const CustomUrlInput = ({ /> onSelect('jsonRpc')} @@ -442,17 +512,18 @@ const CustomUrlInput = ({ ) : ( <> onSelect('jsonRpc')} /> {isDesktopWalletRunning !== null && ( -

    +

    {isDesktopWalletRunning ? ( diff --git a/libs/wallet/src/connect-dialog/index.ts b/libs/wallet/src/connect-dialog/index.ts index a4bcdf25d..ee9771191 100644 --- a/libs/wallet/src/connect-dialog/index.ts +++ b/libs/wallet/src/connect-dialog/index.ts @@ -1,2 +1,3 @@ export * from './connect-dialog'; export * from './view-as-dialog'; +export * from './vega-wallet-dialog-store'; diff --git a/libs/wallet/src/connect-dialog/injected-connector-form.tsx b/libs/wallet/src/connect-dialog/injected-connector-form.tsx index f1c51203d..1fce47bd3 100644 --- a/libs/wallet/src/connect-dialog/injected-connector-form.tsx +++ b/libs/wallet/src/connect-dialog/injected-connector-form.tsx @@ -11,6 +11,7 @@ import { } from '@vegaprotocol/ui-toolkit'; import { setAcknowledged } from '../storage'; import { useVegaWallet } from '../use-vega-wallet'; +import { InjectedConnectorErrors, SnapConnectorErrors } from '../connectors'; export const InjectedConnectorForm = ({ status, @@ -20,7 +21,6 @@ export const InjectedConnectorForm = ({ reset, error, }: { - // connector: JsonRpcConnector; appChainId: string; status: Status; error: Error | null; @@ -109,7 +109,7 @@ export const InjectedConnectorForm = ({ const Center = ({ children }: { children: ReactNode }) => { return ( -

    {children}
    +
    {children}
    ); }; @@ -131,22 +131,30 @@ const Error = ({ ); if (error) { - if (error.message === 'Invalid chain') { + if (error.message === InjectedConnectorErrors.INVALID_CHAIN.message) { title = t('Wrong network'); text = t( 'To complete your wallet connection, set your wallet network in your app to "%s".', appChainId ); - } else if (error.message === 'window.vega not found') { + } else if ( + error.message === InjectedConnectorErrors.VEGA_UNDEFINED.message + ) { title = t('No wallet detected'); text = t('Vega browser extension not installed'); + } else if ( + error.message === SnapConnectorErrors.ETHEREUM_UNDEFINED.message || + error.message === SnapConnectorErrors.NODE_ADDRESS_NOT_SET.message + ) { + title = t('Snap failed'); + text = t('Could not connect to Vega MetaMask Snap'); } } return ( <> {title} -

    {text}

    +

    {text}

    {tryAgain} ); diff --git a/libs/wallet/src/connect-dialog/json-rpc-connector-form.tsx b/libs/wallet/src/connect-dialog/json-rpc-connector-form.tsx index 2aee59d3f..b0443cc46 100644 --- a/libs/wallet/src/connect-dialog/json-rpc-connector-form.tsx +++ b/libs/wallet/src/connect-dialog/json-rpc-connector-form.tsx @@ -14,7 +14,6 @@ import type { JsonRpcConnector } from '../connectors'; import { ClientErrors } from '../connectors'; import { ConnectDialogTitle } from './connect-dialog-elements'; import { Status } from '../use-json-rpc-connect'; -import { DocsLinks } from '@vegaprotocol/environment'; import { useVegaWallet } from '../use-vega-wallet'; import { setAcknowledged } from '../storage'; @@ -156,6 +155,7 @@ const Error = ({ appChainId: string; onTryAgain: () => void; }) => { + const { links } = useVegaWallet(); let title = t('Something went wrong'); let text: ReactNode | undefined = t('An unknown error occurred'); let tryAgain: ReactNode | null = ( @@ -182,11 +182,9 @@ const Error = ({ <> {capitalize(error.message)} {'. '} - {DocsLinks && ( - - {t('Read the docs to troubleshoot')} - - )} + + {t('Read the docs to troubleshoot')} + ); } else if (error.code === ServiceErrors.REQUEST_PROCESSING) { diff --git a/libs/wallet/src/connect-dialog/mozilla-icon.tsx b/libs/wallet/src/connect-dialog/mozilla-icon.tsx new file mode 100644 index 000000000..8197e2cbd --- /dev/null +++ b/libs/wallet/src/connect-dialog/mozilla-icon.tsx @@ -0,0 +1,214 @@ +export const MozillaIcon = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/libs/wallet/src/connect-dialog/vega-wallet-dialog-store.ts b/libs/wallet/src/connect-dialog/vega-wallet-dialog-store.ts new file mode 100644 index 000000000..824be1c1c --- /dev/null +++ b/libs/wallet/src/connect-dialog/vega-wallet-dialog-store.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +export interface VegaWalletDialogStore { + vegaWalletDialogOpen: boolean; + updateVegaWalletDialog: (open: boolean) => void; + openVegaWalletDialog: () => void; + closeVegaWalletDialog: () => void; +} + +export const useVegaWalletDialogStore = create()( + (set) => ({ + vegaWalletDialogOpen: false, + updateVegaWalletDialog: (open: boolean) => + set({ vegaWalletDialogOpen: open }), + openVegaWalletDialog: () => set({ vegaWalletDialogOpen: true }), + closeVegaWalletDialog: () => set({ vegaWalletDialogOpen: false }), + }) +); diff --git a/libs/wallet/src/connectors/index.ts b/libs/wallet/src/connectors/index.ts index 37b96be0f..7ab8c4d74 100644 --- a/libs/wallet/src/connectors/index.ts +++ b/libs/wallet/src/connectors/index.ts @@ -1,4 +1,17 @@ -export * from './vega-connector'; +import type { InjectedConnector } from './injected-connector'; +import type { JsonRpcConnector } from './json-rpc-connector'; +import type { SnapConnector } from './snap-connector'; +import type { ViewConnector } from './view-connector'; + export * from './injected-connector'; export * from './json-rpc-connector'; +export * from './snap-connector'; +export * from './vega-connector'; export * from './view-connector'; + +export type Connectors = { + jsonRpc: JsonRpcConnector | undefined; + injected: InjectedConnector | undefined; + snap: SnapConnector | undefined; + view: ViewConnector | undefined; +}; diff --git a/libs/wallet/src/connectors/injected-connector.ts b/libs/wallet/src/connectors/injected-connector.ts index f0d8754f0..8ebc66b3e 100644 --- a/libs/wallet/src/connectors/injected-connector.ts +++ b/libs/wallet/src/connectors/injected-connector.ts @@ -41,6 +41,11 @@ declare global { } } +export const InjectedConnectorErrors = { + VEGA_UNDEFINED: new Error('window.vega not found'), + INVALID_CHAIN: new Error('Invalid chain'), +}; + export class InjectedConnector implements VegaConnector { description = 'Connects using the Vega wallet browser extension'; diff --git a/libs/wallet/src/connectors/snap-connector.ts b/libs/wallet/src/connectors/snap-connector.ts new file mode 100644 index 000000000..ede6fa70e --- /dev/null +++ b/libs/wallet/src/connectors/snap-connector.ts @@ -0,0 +1,237 @@ +import { + WalletError, + type PubKey, + type Transaction, + type VegaConnector, +} from './vega-connector'; +import { clearConfig, setConfig } from '../storage'; + +type RequestArguments = { + method: string; + params?: unknown[] | object; +}; +type WindowEthereumProvider = { + isMetaMask: boolean; + request(args: RequestArguments): Promise; +}; + +export const SnapConnectorErrors = { + ETHEREUM_UNDEFINED: new Error('MetaMask extension could not be found'), + NODE_ADDRESS_NOT_SET: new Error('nodeAddress is not set'), + SNAP_ID_NOT_SET: new Error('snapId is not set'), + TRANSACTION_PARSE: new Error('could not parse transaction data'), +}; + +const ethereumRequest = (args: RequestArguments): Promise => { + // can't declare `EthereumProvider` here because of the conflict with + // type definitions of `@web3-react` + if ( + 'ethereum' in window && + typeof window.ethereum === 'object' && + window.ethereum && + 'request' in window.ethereum && + 'isMetaMask' in window.ethereum && + window.ethereum.isMetaMask && + typeof window.ethereum.request === 'function' + ) { + return (window.ethereum as WindowEthereumProvider).request(args); + } + throw SnapConnectorErrors.ETHEREUM_UNDEFINED; +}; + +export const LOCAL_SNAP_ID = 'local:http://localhost:8080'; +export const DEFAULT_SNAP_ID = 'npm:@vegaprotocol/snap'; + +type GetSnapsResponse = Record; + +type Snap = { + id: string; + initialPermissions?: Record; + version: string; + enables: boolean; + blocked: boolean; +}; + +type InvokeSnapRequest = { + method: string; + params?: object; +}; + +type SendTransactionResponse = + | { + transactionHash: string; + receivedAt: string; + sentAt: string; + transaction?: { + signature?: { + value: string; + }; + }; + } + | { + error: Error & { + code: number; + data: unknown; + }; + }; +type GetChainIdResponse = { + chainID: string; +}; +type ListKeysResponse = { keys: PubKey[] }; + +/** + * Requests permission for a website to communicate with the specified snaps + * and attempts to install them if they're not already installed. + * If the installation of any snap fails, returns the error that caused the failure. + * More informations here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_requestsnaps + */ +export const requestSnap = async ( + snapId: string, + params: Record<'version' | string, unknown> = {} +) => { + try { + await ethereumRequest({ + method: 'wallet_requestSnaps', + params: { + [snapId]: params, + }, + }); + } catch (err) { + // NOOP - rejected by user + } +}; + +/** + * Gets the list of all installed snaps. + * More information here: https://docs.metamask.io/snaps/reference/rpc-api/#wallet_getsnaps + */ +export const getSnaps = async (): Promise => { + return (await ethereumRequest({ + method: 'wallet_getSnaps', + })) as GetSnapsResponse; +}; + +/** + * Gets the requested snap by `snapId` and an optional `version` + */ +export const getSnap = async ( + snapId: string, + version?: string +): Promise => { + try { + const snaps = await getSnaps(); + return Object.values(snaps).find( + (snap) => snap.id === snapId && (!version || snap.version === version) + ); + } catch (e) { + return undefined; + } +}; + +export const invokeSnap = async ( + snapId: string, + request: InvokeSnapRequest +) => { + const req = { + method: 'wallet_invokeSnap', + params: { + snapId, + request, + }, + }; + return await ethereumRequest(req); +}; + +export class SnapConnector implements VegaConnector { + description = "Connects using Vega Protocol's MetaMask snap"; + snapId: string | undefined = undefined; + nodeAddress: string | undefined = undefined; + + // note we cannot set nodeAddress in the constructor because the + // trading app will not know what the vega url is until the app runs + constructor(snapId = DEFAULT_SNAP_ID) { + this.snapId = snapId; + } + + async listKeys() { + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + return await invokeSnap(this.snapId, { + method: 'client.list_keys', + }); + } + + async connect() { + const res = await this.listKeys(); + setConfig({ + connector: 'snap', + token: null, // no token required for snap + url: null, // no url required for snap + }); + return res?.keys; + } + + async sendTx(pubKey: string, transaction: Transaction) { + if (!this.nodeAddress) throw SnapConnectorErrors.NODE_ADDRESS_NOT_SET; + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + + // This step is needed to strip the transaction object from any additional + // properties, such as `__proto__`, etc. + let txData = null; + try { + txData = JSON.parse(JSON.stringify(transaction)); + } catch (err) { + throw SnapConnectorErrors.TRANSACTION_PARSE; + } + + const payload = { + method: 'client.send_transaction', + params: { + sendingMode: 'TYPE_SYNC', + transaction: txData, + publicKey: pubKey, + networkEndpoints: [this.nodeAddress], + }, + }; + + const result = await invokeSnap( + this.snapId, + payload + ); + + if ('error' in result) { + const { message, code, data } = result.error; + throw new WalletError( + message, + code, + typeof data === 'string' ? data : '' + ); + } + + if (!result?.transaction?.signature) { + throw new Error('could not retrieve transaction siganture'); + } + + return { + transactionHash: result.transactionHash, + signature: result?.transaction?.signature?.value, + receivedAt: result.receivedAt, + sentAt: result.sentAt, + }; + } + + async getChainId(): Promise { + if (!this.nodeAddress) throw SnapConnectorErrors.NODE_ADDRESS_NOT_SET; + if (!this.snapId) throw SnapConnectorErrors.SNAP_ID_NOT_SET; + + const response = await invokeSnap(this.snapId, { + method: 'client.get_chain_id', + params: { networkEndpoints: [this.nodeAddress] }, + }); + + return response; + } + + async disconnect() { + clearConfig(); + } +} diff --git a/libs/wallet/src/context.ts b/libs/wallet/src/context.ts index 704e2b5cc..c2f53f9da 100644 --- a/libs/wallet/src/context.ts +++ b/libs/wallet/src/context.ts @@ -7,6 +7,15 @@ import type { } from './connectors'; export interface VegaWalletContextShape { + /** Current connected network */ + network: string; + + /** Url of current connected node */ + vegaUrl: string; + + /** Url of running wallet service */ + vegaWalletServiceUrl: string; + /** If the current connector does not support signing transactions */ isReadOnly: boolean; /** The current select public key */ @@ -37,6 +46,16 @@ export interface VegaWalletContextShape { /** Acknowledge disclaimer */ acknowledgeNeeded?: boolean; + + /** Useful links for wallet users */ + links: { + explorer: string; + about: string; + concepts: string; + browserList: string; + chromeExtensionUrl: string; + mozillaExtensionUrl: string; + }; } export const VegaWalletContext = createContext< diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts index 3d43a0b18..e9ae2340b 100644 --- a/libs/wallet/src/index.ts +++ b/libs/wallet/src/index.ts @@ -1,17 +1,18 @@ -export * from './context'; -export * from './use-vega-wallet'; export * from './connectors'; +export * from './context'; export * from './use-vega-transaction'; +export * from './use-vega-wallet'; export * from './use-vega-transaction-manager'; export * from './use-vega-transaction-store'; export * from './use-vega-transaction-updater'; export * from './use-transaction-result'; export * from './use-eager-connect'; export * from './manage-dialog'; -export * from './vega-transaction-dialog'; export * from './provider'; export * from './connect-dialog'; export * from './utils'; export * from './storage'; +export * from './types'; +export * from './vega-transaction-dialog'; export * from './__generated__/TransactionResult'; export * from './__generated__/WithdrawalApproval'; diff --git a/libs/wallet/src/manage-dialog/manage-dialog.tsx b/libs/wallet/src/manage-dialog/manage-dialog.tsx index 738837f55..9a746f04e 100644 --- a/libs/wallet/src/manage-dialog/manage-dialog.tsx +++ b/libs/wallet/src/manage-dialog/manage-dialog.tsx @@ -7,7 +7,7 @@ import { Intent, Icon, } from '@vegaprotocol/ui-toolkit'; -import { useVegaWallet } from '..'; +import { useVegaWallet } from '../use-vega-wallet'; export interface VegaManageDialogProps { dialogOpen: boolean; @@ -38,13 +38,13 @@ export const VegaManageDialog = ({ className="mb-2 last:mb-0" >

    {truncateByChars(pk.publicKey)}

    -
    +
    {!isSelected && (