diff --git a/apps/trading/__generated__/globalTypes.ts b/apps/trading/__generated__/globalTypes.ts deleted file mode 100644 index 8d9b7dd7a..000000000 --- a/apps/trading/__generated__/globalTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -//============================================================== -// START Enums and Input Objects -//============================================================== - -//============================================================== -// END Enums and Input Objects -//============================================================== diff --git a/apps/trading/apollo.config.js b/apps/trading/apollo.config.js index b71577567..887eb5abd 100644 --- a/apps/trading/apollo.config.js +++ b/apps/trading/apollo.config.js @@ -4,6 +4,6 @@ module.exports = { name: 'vega', url: process.env.NX_VEGA_URL, }, - includes: ['{components,lib,pages}/**/*.{ts,tsx,js,jsx,graphql}'], + includes: ['{components,lib,pages,hooks}/**/*.{ts,tsx,js,jsx,graphql}'], }, }; diff --git a/apps/trading/components/deal-ticket-container/deal-ticket-container.tsx b/apps/trading/components/deal-ticket-container/deal-ticket-container.tsx new file mode 100644 index 000000000..2f83b19a7 --- /dev/null +++ b/apps/trading/components/deal-ticket-container/deal-ticket-container.tsx @@ -0,0 +1,74 @@ +import { Dialog, Intent } from '@vegaprotocol/ui-toolkit'; +import { DealTicket } from '@vegaprotocol/deal-ticket'; +import { OrderStatus } from '@vegaprotocol/graphql'; +import { useOrderSubmit } from '../../hooks/use-order-submit'; +import { useEffect, useState } from 'react'; +import { VegaTxStatus } from '../../hooks/use-vega-transaction'; +import { OrderDialog } from './order-dialog'; + +export const DealTicketContainer = ({ market }) => { + const [orderDialogOpen, setOrderDialogOpen] = useState(false); + const { submit, transaction, finalizedOrder, reset } = useOrderSubmit(market); + + const getDialogIntent = (status: VegaTxStatus) => { + if (finalizedOrder) { + if ( + finalizedOrder.status === OrderStatus.Active || + finalizedOrder.status === OrderStatus.Filled || + finalizedOrder.status === OrderStatus.PartiallyFilled + ) { + return Intent.Success; + } + + if (finalizedOrder.status === OrderStatus.Parked) { + return Intent.Warning; + } + + return Intent.Danger; + } + + if (status === VegaTxStatus.Rejected) { + return Intent.Danger; + } + + return Intent.Progress; + }; + + useEffect(() => { + if (transaction.status !== VegaTxStatus.Default) { + setOrderDialogOpen(true); + } + }, [transaction.status]); + + return ( + <> + + { + setOrderDialogOpen(isOpen); + + // If closing reset + if (!isOpen) { + reset(); + } + }} + intent={getDialogIntent(transaction.status)} + > + + + + ); +}; diff --git a/apps/trading/components/deal-ticket-container/index.ts b/apps/trading/components/deal-ticket-container/index.ts new file mode 100644 index 000000000..6d3e9ada5 --- /dev/null +++ b/apps/trading/components/deal-ticket-container/index.ts @@ -0,0 +1 @@ +export * from './deal-ticket-container'; diff --git a/apps/trading/components/deal-ticket-container/order-dialog.tsx b/apps/trading/components/deal-ticket-container/order-dialog.tsx new file mode 100644 index 000000000..09938a2b4 --- /dev/null +++ b/apps/trading/components/deal-ticket-container/order-dialog.tsx @@ -0,0 +1,95 @@ +import { Icon, Loader } from '@vegaprotocol/ui-toolkit'; +import { ReactNode } from 'react'; +import { + TransactionState, + VegaTxStatus, +} from '../../hooks/use-vega-transaction'; +import { OrderEvent_busEvents_event_Order } from '@vegaprotocol/graphql'; + +interface OrderDialogProps { + transaction: TransactionState; + finalizedOrder: OrderEvent_busEvents_event_Order | null; +} + +export const OrderDialog = ({ + transaction, + finalizedOrder, +}: OrderDialogProps) => { + // TODO: When wallets support confirming transactions return UI for 'awaiting confirmation' step + + // Rejected by wallet + if (transaction.status === VegaTxStatus.Rejected) { + return ( + } + > + {transaction.error && ( +
+            {JSON.stringify(transaction.error, null, 2)}
+          
+ )} +
+ ); + } + + // Pending consensus + if (!finalizedOrder) { + return ( + } + > + {transaction.hash && ( +

Tx hash: {transaction.hash}

+ )} +
+ ); + } + + // Order on network but was rejected + if (finalizedOrder.status === 'Rejected') { + return ( + } + > +

Reason: {finalizedOrder.rejectionReason}

+
+ ); + } + + return ( + } + > +

Status: {finalizedOrder.status}

+

Market: {finalizedOrder.market.name}

+

Amount: {finalizedOrder.size}

+ {finalizedOrder.type === 'Limit' &&

Price: {finalizedOrder.price}

} +
+ ); +}; + +interface OrderDialogWrapperProps { + children: ReactNode; + icon: ReactNode; + title: string; +} + +const OrderDialogWrapper = ({ + children, + icon, + title, +}: OrderDialogWrapperProps) => { + return ( +
+
{icon}
+
+

{title}

+ {children} +
+
+ ); +}; diff --git a/apps/trading/components/page-query-container/index.tsx b/apps/trading/components/page-query-container/index.tsx index f1e7bf9ae..455302743 100644 --- a/apps/trading/components/page-query-container/index.tsx +++ b/apps/trading/components/page-query-container/index.tsx @@ -1,7 +1,7 @@ import { OperationVariables, QueryHookOptions, useQuery } from '@apollo/client'; -import classNames from 'classnames'; import { DocumentNode } from 'graphql'; import { ReactNode } from 'react'; +import { Splash } from '@vegaprotocol/ui-toolkit'; interface PageQueryContainerProps { query: DocumentNode; @@ -15,19 +15,13 @@ export const PageQueryContainer = ({ children, }: PageQueryContainerProps) => { const { data, loading, error } = useQuery(query, options); - const splashClasses = classNames( - 'w-full h-full', - 'flex items-center justify-center' - ); if (loading || !data) { - return
Loading...
; + return Loading...; } if (error) { - return ( -
Something went wrong: {error.message}
- ); + return Something went wrong: {error.message}; } return <>{children(data)}; diff --git a/apps/trading/hooks/use-order-submit.spec.tsx b/apps/trading/hooks/use-order-submit.spec.tsx new file mode 100644 index 000000000..2bc3e5979 --- /dev/null +++ b/apps/trading/hooks/use-order-submit.spec.tsx @@ -0,0 +1,153 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { Order } from '@vegaprotocol/deal-ticket'; +import { + VegaKeyExtended, + VegaWalletContext, + VegaWalletContextShape, +} from '@vegaprotocol/wallet'; +import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet'; +import { ReactNode } from 'react'; +import { useOrderSubmit } from './use-order-submit'; +import { VegaTxStatus } from './use-vega-transaction'; + +const defaultWalletContext = { + keypair: null, + keypairs: [], + sendTx: jest.fn().mockReturnValue(Promise.resolve(null)), + connect: jest.fn(), + disconnect: jest.fn(), + selectPublicKey: jest.fn(), + connector: null, +}; + +function setup( + context?: Partial, + market = { id: 'market-id', decimalPlaces: 2 } +) { + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return renderHook(() => useOrderSubmit(market), { wrapper }); +} + +test('Has the correct default state', () => { + const { result } = setup(); + expect(typeof result.current.submit).toEqual('function'); + expect(typeof result.current.reset).toEqual('function'); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); + expect(result.current.transaction.hash).toEqual(null); + expect(result.current.transaction.error).toEqual(null); +}); + +test('Should not sendTx if no keypair', async () => { + const mockSendTx = jest.fn(); + const { result } = setup({ sendTx: mockSendTx, keypairs: [], keypair: null }); + await act(async () => { + result.current.submit({} as Order); + }); + expect(mockSendTx).not.toHaveBeenCalled(); +}); + +test('Should not sendTx side is not specified', async () => { + const mockSendTx = jest.fn(); + const keypair = { + pub: '0x123', + } as VegaKeyExtended; + const { result } = setup({ + sendTx: mockSendTx, + keypairs: [keypair], + keypair, + }); + await act(async () => { + result.current.submit({} as Order); + }); + expect(mockSendTx).not.toHaveBeenCalled(); +}); + +test('Create an Id if a signature is returned', async () => { + const signature = + '597a7706491e6523c091bab1e4d655b62c45a224e80f6cd92ac366aa5dd9a070cc7dd3c6919cb07b81334b876c662dd43bdbe5e827c8baa17a089feb654fab0b'; + const expectedId = + '2FE09B0E2E6ED35F8883802629C7D609D3CC2FC9CE3CEC0B7824A0D581BD3747'; + const successObj = { + tx: { + inputData: 'input-data', + signature: { + algo: 'algo', + version: 1, + value: signature, + }, + }, + txHash: '0x123', + }; + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj)); + const keypair = { + pub: '0x123', + } as VegaKeyExtended; + const { result } = setup({ + sendTx: mockSendTx, + keypairs: [keypair], + keypair, + }); + await act(async () => { + result.current.submit({ + type: OrderType.Market, + side: OrderSide.Buy, + size: '1', + timeInForce: OrderTimeInForce.FOK, + }); + }); + expect(result.current.id).toEqual(expectedId); +}); + +test('Should submit a correctly formatted order', async () => { + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve({})); + const keypair = { + pub: '0x123', + } as VegaKeyExtended; + const market = { + id: 'market-id', + decimalPlaces: 2, + }; + const { result } = setup( + { + sendTx: mockSendTx, + keypairs: [keypair], + keypair, + }, + market + ); + + const order = { + type: OrderType.Limit, + size: '10', + timeInForce: OrderTimeInForce.GTT, + side: OrderSide.Buy, + price: '1234567.89', + expiration: new Date('2022-01-01'), + }; + await act(async () => { + result.current.submit(order); + }); + + expect(mockSendTx).toHaveBeenCalledWith({ + pubKey: keypair.pub, + propagate: true, + orderSubmission: { + type: OrderType.Limit, + marketId: market.id, // Market provided from hook arugment + size: '10', + side: OrderSide.Buy, + timeInForce: OrderTimeInForce.GTT, + price: '123456789', // Decimal removed + expiresAt: order.expiration.getTime() + '000000', // Nanoseconds appened + }, + }); +}); diff --git a/apps/trading/hooks/use-order-submit.ts b/apps/trading/hooks/use-order-submit.ts new file mode 100644 index 000000000..c81a61062 --- /dev/null +++ b/apps/trading/hooks/use-order-submit.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useState } from 'react'; +import { gql, useSubscription } from '@apollo/client'; +import { ethers } from 'ethers'; +import { SHA3 } from 'sha3'; +import { Order } from '@vegaprotocol/deal-ticket'; +import { OrderType, useVegaWallet } from '@vegaprotocol/wallet'; +import { useVegaTransaction } from './use-vega-transaction'; +import { + OrderEvent, + OrderEventVariables, + OrderEvent_busEvents_event_Order, +} from '@vegaprotocol/graphql'; +import { removeDecimal } from '@vegaprotocol/react-helpers'; + +const ORDER_EVENT_SUB = gql` + subscription OrderEvent($partyId: ID!) { + busEvents(partyId: $partyId, batchSize: 0, types: [Order]) { + eventId + block + type + event { + ... on Order { + type + id + status + rejectionReason + createdAt + size + price + market { + name + } + } + } + } + } +`; + +interface UseOrderSubmitMarket { + id: string; + decimalPlaces: number; +} + +export const useOrderSubmit = (market: UseOrderSubmitMarket) => { + const { keypair } = useVegaWallet(); + const { send, transaction, reset: resetTransaction } = useVegaTransaction(); + const [id, setId] = useState(''); + const [finalizedOrder, setFinalizedOrder] = + useState(null); + + // Start a subscription looking for the newly created order + useSubscription(ORDER_EVENT_SUB, { + variables: { partyId: keypair?.pub || '' }, + skip: !id, + onSubscriptionData: ({ subscriptionData }) => { + if (!subscriptionData.data.busEvents.length) { + return; + } + + // No types available for the subscription result + const matchingOrderEvent = subscriptionData.data.busEvents.find((e) => { + if (e.event.__typename !== 'Order') { + return false; + } + + if (e.event.id === id) { + return true; + } + + return false; + }); + + if ( + matchingOrderEvent && + matchingOrderEvent.event.__typename === 'Order' + ) { + setFinalizedOrder(matchingOrderEvent.event); + } + }, + }); + + useEffect(() => { + if (finalizedOrder) { + resetTransaction(); + } + }, [finalizedOrder, resetTransaction]); + + const submit = useCallback( + async (order: Order) => { + if (!keypair || !order.side) { + return; + } + + setFinalizedOrder(null); + + const res = await send({ + pubKey: keypair.pub, + propagate: true, + orderSubmission: { + marketId: market.id, + price: + order.type === OrderType.Market + ? undefined + : removeDecimal(order.price, market.decimalPlaces), + size: order.size, + type: order.type, + side: order.side, + timeInForce: order.timeInForce, + expiresAt: order.expiration + ? // Wallet expects timestamp in nanoseconds, we don't have that level of accuracy so + // just append 6 zeroes + order.expiration.getTime().toString() + '000000' + : undefined, + }, + }); + + if (res?.signature) { + setId(determineId(res.signature).toUpperCase()); + } + }, + [market, keypair, send] + ); + + const reset = useCallback(() => { + resetTransaction(); + setFinalizedOrder(null); + setId(''); + }, [resetTransaction]); + + return { + transaction, + finalizedOrder, + id, + submit, + reset, + }; +}; + +/** + * This function creates an ID in the same way that core does on the backend. This way we + * Can match up the newly created order with incoming orders via a subscription + */ +export const determineId = (sig: string) => { + // Prepend 0x + if (sig.slice(0, 2) !== '0x') { + sig = '0x' + sig; + } + + // Create the ID + const hash = new SHA3(256); + const bytes = ethers.utils.arrayify(sig); + hash.update(Buffer.from(bytes)); + const id = ethers.utils.hexlify(hash.digest()); + + // Remove 0x as core doesn't keep them in the API + return id.substring(2); +}; diff --git a/apps/trading/hooks/use-vega-transaction.spec.tsx b/apps/trading/hooks/use-vega-transaction.spec.tsx new file mode 100644 index 000000000..5b7be1f4d --- /dev/null +++ b/apps/trading/hooks/use-vega-transaction.spec.tsx @@ -0,0 +1,98 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { + OrderSubmission, + VegaWalletContext, + VegaWalletContextShape, +} from '@vegaprotocol/wallet'; +import { ReactNode } from 'react'; +import { useVegaTransaction, VegaTxStatus } from './use-vega-transaction'; + +const defaultWalletContext = { + keypair: null, + keypairs: [], + sendTx: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + selectPublicKey: jest.fn(), + connector: null, +}; + +function setup(context?: Partial) { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useVegaTransaction(), { wrapper }); +} + +test('Has the correct default state', () => { + const { result } = setup(); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); + expect(result.current.transaction.hash).toEqual(null); + expect(result.current.transaction.signature).toEqual(null); + expect(result.current.transaction.error).toEqual(null); + expect(typeof result.current.reset).toEqual('function'); + expect(typeof result.current.send).toEqual('function'); +}); + +test('If provider returns null status should be default', async () => { + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(null)); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send({} as OrderSubmission); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); +}); + +test('Handles a single error', async () => { + const errorMessage = 'Oops error!'; + const mockSendTx = jest + .fn() + .mockReturnValue(Promise.resolve({ error: errorMessage })); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send({} as OrderSubmission); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Rejected); + expect(result.current.transaction.error).toEqual({ error: errorMessage }); +}); + +test('Handles multiple errors', async () => { + const errorObj = { + errors: { + something: 'Went wrong!', + }, + }; + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(errorObj)); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send({} as OrderSubmission); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Rejected); + expect(result.current.transaction.error).toEqual(errorObj); +}); + +test('Returns the signature if successful', async () => { + const successObj = { + tx: { + inputData: 'input-data', + signature: { + algo: 'algo', + version: 1, + value: 'signature', + }, + }, + txHash: '0x123', + }; + const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj)); + const { result } = setup({ sendTx: mockSendTx }); + await act(async () => { + result.current.send({} as OrderSubmission); + }); + expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending); + expect(result.current.transaction.hash).toEqual(successObj.txHash); + expect(result.current.transaction.signature).toEqual( + successObj.tx.signature.value + ); +}); diff --git a/apps/trading/hooks/use-vega-transaction.ts b/apps/trading/hooks/use-vega-transaction.ts new file mode 100644 index 000000000..6765b8ad5 --- /dev/null +++ b/apps/trading/hooks/use-vega-transaction.ts @@ -0,0 +1,89 @@ +import { useCallback, useState } from 'react'; +import { useVegaWallet, SendTxError, Transaction } from '@vegaprotocol/wallet'; + +export enum VegaTxStatus { + Default = 'Default', + AwaitingConfirmation = 'AwaitingConfirmation', + Rejected = 'Rejected', + Pending = 'Pending', +} + +export interface TransactionState { + status: VegaTxStatus; + error: object | null; + hash: string | null; + signature: string | null; +} + +export const useVegaTransaction = () => { + const { sendTx } = useVegaWallet(); + const [transaction, _setTransaction] = useState({ + status: VegaTxStatus.Default, + error: null, + hash: null, + signature: null, + }); + + const setTransaction = useCallback((update: Partial) => { + _setTransaction((curr) => ({ + ...curr, + ...update, + })); + }, []); + + const handleError = useCallback( + (error: SendTxError) => { + setTransaction({ error, status: VegaTxStatus.Rejected }); + }, + [setTransaction] + ); + + const send = useCallback( + async (tx: Transaction) => { + setTransaction({ + error: null, + hash: null, + signature: null, + status: VegaTxStatus.AwaitingConfirmation, + }); + + const res = await sendTx(tx); + + if (res === null) { + setTransaction({ status: VegaTxStatus.Default }); + return null; + } + + if ('error' in res) { + handleError(res); + return null; + } else if ('errors' in res) { + handleError(res); + return null; + } else if (res.tx && res.txHash) { + setTransaction({ + status: VegaTxStatus.Pending, + hash: res.txHash, + signature: res.tx.signature.value, + }); + return { + signature: res.tx.signature?.value, + }; + } + + return null; + }, + [sendTx, handleError, setTransaction] + ); + + const reset = useCallback(() => { + setTransaction({ + error: null, + hash: null, + signature: null, + status: VegaTxStatus.Default, + }); + }, [setTransaction]); + + return { send, transaction, reset }; +}; diff --git a/apps/trading/pages/index.page.tsx b/apps/trading/pages/index.page.tsx index 6b5bd7a2b..3377e03af 100644 --- a/apps/trading/pages/index.page.tsx +++ b/apps/trading/pages/index.page.tsx @@ -2,6 +2,7 @@ import { AgGridDynamic as AgGrid, Button, Callout, + Intent, } from '@vegaprotocol/ui-toolkit'; import { AgGridColumn } from 'ag-grid-react'; @@ -15,7 +16,7 @@ export function Index() {
{ marketId: Array.isArray(marketId) ? marketId[0] : marketId, }, skip: !marketId, + fetchPolicy: 'network-only', }} > - {({ market }) => - w > 1050 ? ( + {({ market }) => { + if (!market) { + return Market not found; + } + + return w > 960 ? ( ) : ( - ) - } + ); + }} ); }; diff --git a/apps/trading/pages/markets/__generated__/Market.ts b/apps/trading/pages/markets/__generated__/Market.ts deleted file mode 100644 index 44933dd5d..000000000 --- a/apps/trading/pages/markets/__generated__/Market.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -// ==================================================== -// GraphQL query operation: Market -// ==================================================== - -export interface Market_market_trades { - __typename: "Trade"; - /** - * The hash of the trade data - */ - id: string; - /** - * The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64) - */ - price: string; - /** - * The number of contracts trades, will always be <= the remaining size of both orders immediately before the trade (uint64) - */ - size: string; - /** - * RFC3339Nano time for when the trade occurred - */ - createdAt: string; -} - -export interface Market_market { - __typename: "Market"; - /** - * Market ID - */ - id: string; - /** - * Market full name - */ - name: string; - /** - * Trades on a market - */ - trades: Market_market_trades[] | null; -} - -export interface Market { - /** - * An instrument that is trading on the VEGA network - */ - market: Market_market | null; -} - -export interface MarketVariables { - marketId: string; -} diff --git a/apps/trading/pages/markets/index.page.tsx b/apps/trading/pages/markets/index.page.tsx index 70e2c3ba8..4572c810a 100644 --- a/apps/trading/pages/markets/index.page.tsx +++ b/apps/trading/pages/markets/index.page.tsx @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; +import { Markets } from '@vegaprotocol/graphql'; import { PageQueryContainer } from '../../components/page-query-container'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { Markets } from './__generated__/Markets'; const MARKETS_QUERY = gql` query Markets { diff --git a/apps/trading/pages/markets/trade-grid.tsx b/apps/trading/pages/markets/trade-grid.tsx index 2ae0e2538..b3b9b4c42 100644 --- a/apps/trading/pages/markets/trade-grid.tsx +++ b/apps/trading/pages/markets/trade-grid.tsx @@ -1,9 +1,26 @@ +import { Market_market } from '@vegaprotocol/graphql'; import classNames from 'classnames'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useState, ReactNode } from 'react'; -import { TradingView, TradingViews } from '@vegaprotocol/react-helpers'; -import { Market_market } from './__generated__/Market'; import { GridTab, GridTabs } from './grid-tabs'; +import { DealTicketContainer } from '../../components/deal-ticket-container'; + +const Chart = () =>
TODO: Chart
; +const Orderbook = () =>
TODO: Orderbook
; +const Orders = () =>
TODO: Orders
; +const Positions = () =>
TODO: Positions
; +const Collateral = () =>
TODO: Collateral
; + +type TradingView = keyof typeof TradingViews; + +const TradingViews = { + chart: Chart, + ticket: DealTicketContainer, + orderbook: Orderbook, + orders: Orders, + positions: Positions, + collateral: Collateral, +}; interface TradeGridProps { market: Market_market; @@ -25,7 +42,7 @@ export const TradeGrid = ({ market }: TradeGridProps) => { - + @@ -88,7 +105,7 @@ export const TradePanels = ({ market }: TradePanelsProps) => { throw new Error(`No component for view: ${view}`); } - return ; + return ; }; return ( diff --git a/apps/trading/project.json b/apps/trading/project.json index fc87faf48..f96461b2e 100644 --- a/apps/trading/project.json +++ b/apps/trading/project.json @@ -54,7 +54,7 @@ "options": { "commands": [ { - "command": "npx apollo client:codegen --config=apps/trading/apollo.config.js --target=typescript --globalTypesFile=apps/trading/__generated__/globalTypes.ts" + "command": "npx apollo client:codegen libs/graphql/src/lib/ --config=apps/trading/apollo.config.js --target=typescript --globalTypesFile=libs/graphql/src/lib/globalTypes.ts --outputFlat" } ] } diff --git a/libs/deal-ticket/.babelrc b/libs/deal-ticket/.babelrc new file mode 100644 index 000000000..ccae900be --- /dev/null +++ b/libs/deal-ticket/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nrwl/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/deal-ticket/.eslintrc.json b/libs/deal-ticket/.eslintrc.json new file mode 100644 index 000000000..734ddacee --- /dev/null +++ b/libs/deal-ticket/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/deal-ticket/README.md b/libs/deal-ticket/README.md new file mode 100644 index 000000000..8e0882636 --- /dev/null +++ b/libs/deal-ticket/README.md @@ -0,0 +1,7 @@ +# deal-ticket + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test deal-ticket` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/deal-ticket/jest.config.js b/libs/deal-ticket/jest.config.js new file mode 100644 index 000000000..e194e5637 --- /dev/null +++ b/libs/deal-ticket/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + displayName: 'deal-ticket', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/deal-ticket', +}; diff --git a/libs/deal-ticket/package.json b/libs/deal-ticket/package.json new file mode 100644 index 000000000..b5b50d47e --- /dev/null +++ b/libs/deal-ticket/package.json @@ -0,0 +1,4 @@ +{ + "name": "@vegaprotocol/deal-ticket", + "version": "0.0.1" +} diff --git a/libs/deal-ticket/project.json b/libs/deal-ticket/project.json new file mode 100644 index 000000000..941520f1a --- /dev/null +++ b/libs/deal-ticket/project.json @@ -0,0 +1,43 @@ +{ + "root": "libs/deal-ticket", + "sourceRoot": "libs/deal-ticket/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nrwl/web:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/deal-ticket", + "tsConfig": "libs/deal-ticket/tsconfig.lib.json", + "project": "libs/deal-ticket/package.json", + "entryFile": "libs/deal-ticket/src/index.ts", + "external": ["react/jsx-runtime"], + "rollupConfig": "@nrwl/react/plugins/bundle-rollup", + "compiler": "babel", + "assets": [ + { + "glob": "libs/deal-ticket/README.md", + "input": ".", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/deal-ticket/**/*.{ts,tsx,js,jsx}"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/deal-ticket"], + "options": { + "jestConfig": "libs/deal-ticket/jest.config.js", + "passWithNoTests": true + } + } + } +} diff --git a/libs/deal-ticket/src/button-radio.tsx b/libs/deal-ticket/src/button-radio.tsx new file mode 100644 index 000000000..a735e8265 --- /dev/null +++ b/libs/deal-ticket/src/button-radio.tsx @@ -0,0 +1,38 @@ +import { Button } from '@vegaprotocol/ui-toolkit'; + +interface ButtonRadioProps { + name: string; + options: Array<{ value: string; text: string }>; + currentOption: string | null; + onSelect: (option: string) => void; +} + +export const ButtonRadio = ({ + name, + options, + currentOption, + onSelect, +}: ButtonRadioProps) => { + return ( +
+ {options.map((option) => { + const isSelected = option.value === currentOption; + return ( + + ); + })} +
+ ); +}; diff --git a/libs/deal-ticket/src/deal-ticket-limit.tsx b/libs/deal-ticket/src/deal-ticket-limit.tsx new file mode 100644 index 000000000..b327ccacd --- /dev/null +++ b/libs/deal-ticket/src/deal-ticket-limit.tsx @@ -0,0 +1,78 @@ +import { FormGroup, Input } from '@vegaprotocol/ui-toolkit'; +import { OrderTimeInForce } from '@vegaprotocol/wallet'; +import { TransactionStatus } from './deal-ticket'; +import { Market_market } from '@vegaprotocol/graphql'; +import { ExpirySelector } from './expiry-selector'; +import { SideSelector } from './side-selector'; +import { SubmitButton } from './submit-button'; +import { TimeInForceSelector } from './time-in-force-selector'; +import { TypeSelector } from './type-selector'; +import { Order } from './use-order-state'; + +interface DealTicketLimitProps { + order: Order; + updateOrder: (order: Partial) => void; + transactionStatus: TransactionStatus; + market: Market_market; +} + +export const DealTicketLimit = ({ + order, + updateOrder, + transactionStatus, + market, +}: DealTicketLimitProps) => { + return ( + <> + updateOrder({ type })} /> + updateOrder({ side })} /> +
+
+ + updateOrder({ size: e.target.value })} + className="w-full" + type="number" + data-testid="order-size" + /> + +
+
@
+
+ + updateOrder({ price: e.target.value })} + className="w-full" + type="number" + data-testid="order-price" + /> + +
+
+ updateOrder({ timeInForce })} + /> + {order.timeInForce === OrderTimeInForce.GTT && ( + { + if (date) { + updateOrder({ expiration: date }); + } + }} + /> + )} + + + ); +}; diff --git a/libs/deal-ticket/src/deal-ticket-market.tsx b/libs/deal-ticket/src/deal-ticket-market.tsx new file mode 100644 index 000000000..3d46e4770 --- /dev/null +++ b/libs/deal-ticket/src/deal-ticket-market.tsx @@ -0,0 +1,63 @@ +import { addDecimal } from '@vegaprotocol/react-helpers'; +import { FormGroup, Input } from '@vegaprotocol/ui-toolkit'; +import { Market_market } from '@vegaprotocol/graphql'; +import { TransactionStatus } from './deal-ticket'; +import { SideSelector } from './side-selector'; +import { SubmitButton } from './submit-button'; +import { TimeInForceSelector } from './time-in-force-selector'; +import { TypeSelector } from './type-selector'; +import { Order } from './use-order-state'; + +interface DealTicketMarketProps { + order: Order; + updateOrder: (order: Partial) => void; + transactionStatus: TransactionStatus; + market: Market_market; +} + +export const DealTicketMarket = ({ + order, + updateOrder, + transactionStatus, + market, +}: DealTicketMarketProps) => { + return ( + <> + updateOrder({ type })} /> + updateOrder({ side })} /> +
+
+ + updateOrder({ size: e.target.value })} + className="w-full" + type="number" + data-testid="order-size" + /> + +
+
@
+
+ {market.depth.lastTrade ? ( + <> + ~{addDecimal(market.depth.lastTrade.price, market.decimalPlaces)}{' '} + {market.tradableInstrument.instrument.product.quoteName} + + ) : ( + '-' + )} +
+
+ updateOrder({ timeInForce })} + /> + + + ); +}; diff --git a/libs/deal-ticket/src/deal-ticket.spec.tsx b/libs/deal-ticket/src/deal-ticket.spec.tsx new file mode 100644 index 000000000..d259afe5b --- /dev/null +++ b/libs/deal-ticket/src/deal-ticket.spec.tsx @@ -0,0 +1,141 @@ +import '@testing-library/jest-dom'; +import { + VegaWalletContext, + OrderTimeInForce, + OrderType, +} from '@vegaprotocol/wallet'; +import { addDecimal } from '@vegaprotocol/react-helpers'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DealTicket, Market } from './deal-ticket'; +import { Order } from './use-order-state'; + +const order: Order = { + type: OrderType.Market, + size: '100', + timeInForce: OrderTimeInForce.FOK, + side: null, +}; +const market: Market = { + id: 'market-id', + decimalPlaces: 2, + tradingMode: 'Continuous', + state: 'Active', + tradableInstrument: { + instrument: { + product: { + quoteName: 'quote-name', + settlementAsset: { + id: 'asset-id', + symbol: 'asset-symbol', + name: 'asset-name', + }, + }, + }, + }, + depth: { + lastTrade: { + price: '100', + }, + }, +}; + +function generateJsx() { + return ( + + + + ); +} + +test('Deal ticket defaults', () => { + render(generateJsx()); + + // Assert defaults are used + expect( + screen.getByTestId(`order-type-${order.type}-selected`) + ).toBeInTheDocument(); + expect( + screen.queryByTestId('order-side-SIDE_BUY-selected') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('order-side-SIDE_SELL-selected') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('order-size')).toHaveDisplayValue(order.size); + expect(screen.getByTestId('order-tif')).toHaveValue(order.timeInForce); + + // Assert last price is shown + expect(screen.getByTestId('last-price')).toHaveTextContent( + // eslint-disable-next-line + `~${addDecimal(market.depth.lastTrade!.price, market.decimalPlaces)} ${ + market.tradableInstrument.instrument.product.quoteName + }` + ); +}); + +test('Can edit deal ticket', () => { + render(generateJsx()); + + // Asssert changing values + fireEvent.click(screen.getByTestId('order-side-SIDE_BUY')); + expect( + screen.getByTestId('order-side-SIDE_BUY-selected') + ).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('order-size'), { + target: { value: '200' }, + }); + expect(screen.getByTestId('order-size')).toHaveDisplayValue('200'); + + fireEvent.change(screen.getByTestId('order-tif'), { + target: { value: OrderTimeInForce.IOC }, + }); + expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.IOC); + + // Switch to limit order + fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + + // Assert price input shown with default value + expect(screen.getByTestId('order-price')).toHaveDisplayValue('0'); + + // Check all TIF options shown + expect(screen.getByTestId('order-tif').children).toHaveLength( + Object.keys(OrderTimeInForce).length + ); +}); + +test('Handles TIF select box dependent on order type', () => { + render(generateJsx()); + + // Check only IOC and + expect( + Array.from(screen.getByTestId('order-tif').children).map( + (o) => o.textContent + ) + ).toEqual(['IOC', 'FOK']); + + // Switch to limit order and check all TIF options shown + fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + expect(screen.getByTestId('order-tif').children).toHaveLength( + Object.keys(OrderTimeInForce).length + ); + + // Change to GTC + fireEvent.change(screen.getByTestId('order-tif'), { + target: { value: OrderTimeInForce.GTC }, + }); + expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.GTC); + + // Switch back to market order and TIF should now be IOC + fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); + expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.IOC); + + // Switch tif to FOK + fireEvent.change(screen.getByTestId('order-tif'), { + target: { value: OrderTimeInForce.FOK }, + }); + expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.FOK); + + // Change back to limit and check we are still on FOK + fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.FOK); +}); diff --git a/libs/deal-ticket/src/deal-ticket.tsx b/libs/deal-ticket/src/deal-ticket.tsx new file mode 100644 index 000000000..a49dbf958 --- /dev/null +++ b/libs/deal-ticket/src/deal-ticket.tsx @@ -0,0 +1,69 @@ +import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet'; +import { Market_market } from '@vegaprotocol/graphql'; +import { FormEvent } from 'react'; +import { Order, useOrderState } from './use-order-state'; +import { DealTicketMarket } from './deal-ticket-market'; +import { DealTicketLimit } from './deal-ticket-limit'; + +const DEFAULT_ORDER: Order = { + type: OrderType.Market, + side: OrderSide.Buy, + size: '1', + timeInForce: OrderTimeInForce.IOC, +}; + +// TODO: Consider using a generated type when we have a better solution for +// sharing the types from GQL + +export type TransactionStatus = 'default' | 'pending'; + +export interface DealTicketProps { + market: Market_market; + submit: (order: Order) => void; + transactionStatus: TransactionStatus; + defaultOrder?: Order; +} + +export const DealTicket = ({ + market, + submit, + transactionStatus, + defaultOrder = DEFAULT_ORDER, +}: DealTicketProps) => { + const [order, updateOrder] = useOrderState(defaultOrder); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + submit(order); + }; + + let ticket = null; + + if (order.type === OrderType.Market) { + ticket = ( + + ); + } else if (order.type === OrderType.Limit) { + ticket = ( + + ); + } else { + throw new Error('Invalid ticket type'); + } + + return ( +
+ {ticket} +
+ ); +}; diff --git a/libs/deal-ticket/src/expiry-selector.tsx b/libs/deal-ticket/src/expiry-selector.tsx new file mode 100644 index 000000000..01f01243d --- /dev/null +++ b/libs/deal-ticket/src/expiry-selector.tsx @@ -0,0 +1,26 @@ +import { FormGroup, Input } from '@vegaprotocol/ui-toolkit'; +import { Order } from './use-order-state'; +import { formatForInput } from '@vegaprotocol/react-helpers'; + +interface ExpirySelectorProps { + order: Order; + onSelect: (expiration: Date | null) => void; +} + +export const ExpirySelector = ({ order, onSelect }: ExpirySelectorProps) => { + const date = order.expiration ? new Date(order.expiration) : new Date(); + const dateFormatted = formatForInput(date); + const minDate = formatForInput(date); + return ( + + onSelect(new Date(e.target.value))} + min={minDate} + /> + + ); +}; diff --git a/libs/deal-ticket/src/index.ts b/libs/deal-ticket/src/index.ts new file mode 100644 index 000000000..22bedf9bd --- /dev/null +++ b/libs/deal-ticket/src/index.ts @@ -0,0 +1,2 @@ +export * from './deal-ticket'; +export * from './use-order-state'; diff --git a/libs/deal-ticket/src/side-selector.tsx b/libs/deal-ticket/src/side-selector.tsx new file mode 100644 index 000000000..029ea318a --- /dev/null +++ b/libs/deal-ticket/src/side-selector.tsx @@ -0,0 +1,25 @@ +import { FormGroup } from '@vegaprotocol/ui-toolkit'; +import { OrderSide } from '@vegaprotocol/wallet'; +import { ButtonRadio } from './button-radio'; +import { Order } from './use-order-state'; + +interface SideSelectorProps { + order: Order; + onSelect: (side: OrderSide) => void; +} + +export const SideSelector = ({ order, onSelect }: SideSelectorProps) => { + return ( + + ({ + text, + value, + }))} + currentOption={order.side} + onSelect={(value) => onSelect(value as OrderSide)} + /> + + ); +}; diff --git a/libs/deal-ticket/src/submit-button.tsx b/libs/deal-ticket/src/submit-button.tsx new file mode 100644 index 000000000..610c78633 --- /dev/null +++ b/libs/deal-ticket/src/submit-button.tsx @@ -0,0 +1,78 @@ +import { Button, InputError } from '@vegaprotocol/ui-toolkit'; +import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet'; +import { Market_market } from '@vegaprotocol/graphql'; +import { useMemo } from 'react'; +import { Order } from './use-order-state'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { TransactionStatus } from './deal-ticket'; + +interface SubmitButtonProps { + transactionStatus: TransactionStatus; + market: Market_market; + order: Order; +} + +export const SubmitButton = ({ + market, + transactionStatus, + order, +}: SubmitButtonProps) => { + const { keypair } = useVegaWallet(); + + const invalidText = useMemo(() => { + if (!keypair) { + return 'No public key selected'; + } + + if (keypair.tainted) { + return 'Selected public key has been tainted'; + } + + // TODO: Change these to use enums from @vegaprotocol/graphql + if (market.state !== 'Active') { + if (market.state === 'Suspended') { + return 'Market is currently suspended'; + } + + if (market.state === 'Proposed' || market.state === 'Pending') { + return 'Market is not active yet'; + } + + return 'Market is no longer active'; + } + + if (market.tradingMode !== 'Continuous') { + if (order.type === OrderType.Market) { + return 'Only limit orders are permitted when market is in auction'; + } + + if ( + [ + OrderTimeInForce.FOK, + OrderTimeInForce.IOC, + OrderTimeInForce.GFN, + ].includes(order.timeInForce) + ) { + return 'Only GTT, GTC and GFA are permitted when market is in auction'; + } + } + + return ''; + }, [keypair, market, order]); + + const disabled = transactionStatus === 'pending' || Boolean(invalidText); + + return ( + <> + + {invalidText && {invalidText}} + + ); +}; diff --git a/libs/deal-ticket/src/time-in-force-selector.tsx b/libs/deal-ticket/src/time-in-force-selector.tsx new file mode 100644 index 000000000..6c793ea46 --- /dev/null +++ b/libs/deal-ticket/src/time-in-force-selector.tsx @@ -0,0 +1,40 @@ +import { FormGroup, Select } from '@vegaprotocol/ui-toolkit'; +import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet'; +import { Order } from './use-order-state'; + +interface TimeInForceSelectorProps { + order: Order; + onSelect: (tif: OrderTimeInForce) => void; +} + +export const TimeInForceSelector = ({ + order, + onSelect, +}: TimeInForceSelectorProps) => { + const options = + order.type === OrderType.Limit + ? Object.entries(OrderTimeInForce) + : Object.entries(OrderTimeInForce).filter( + ([key, value]) => + value === OrderTimeInForce.FOK || value === OrderTimeInForce.IOC + ); + + return ( + + + + ); +}; diff --git a/libs/deal-ticket/src/type-selector.tsx b/libs/deal-ticket/src/type-selector.tsx new file mode 100644 index 000000000..b3621843a --- /dev/null +++ b/libs/deal-ticket/src/type-selector.tsx @@ -0,0 +1,25 @@ +import { FormGroup } from '@vegaprotocol/ui-toolkit'; +import { OrderType } from '@vegaprotocol/wallet'; +import { ButtonRadio } from './button-radio'; +import { Order } from './use-order-state'; + +interface TypeSelectorProps { + order: Order; + onSelect: (type: OrderType) => void; +} + +export const TypeSelector = ({ order, onSelect }: TypeSelectorProps) => { + return ( + + ({ + text, + value, + }))} + currentOption={order.type} + onSelect={(value) => onSelect(value as OrderType)} + /> + + ); +}; diff --git a/libs/deal-ticket/src/use-order-state.ts b/libs/deal-ticket/src/use-order-state.ts new file mode 100644 index 000000000..42545ce01 --- /dev/null +++ b/libs/deal-ticket/src/use-order-state.ts @@ -0,0 +1,78 @@ +import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet'; +import { useState, useCallback } from 'react'; + +export interface Order { + size: string; + type: OrderType; + timeInForce: OrderTimeInForce; + side: OrderSide | null; + price?: string; + expiration?: Date; +} + +export type UpdateOrder = (order: Partial) => void; + +export const useOrderState = (defaultOrder: Order): [Order, UpdateOrder] => { + const [order, setOrder] = useState(defaultOrder); + + const updateOrder = useCallback((orderUpdate: Partial) => { + setOrder((curr) => { + // Type is switching to market so return new market order object with correct defaults + if ( + orderUpdate.type === OrderType.Market && + curr.type !== OrderType.Market + ) { + // Check if provided TIF or current TIF is valid for a market order and default + // to IOC if its not + + const isTifValid = (tif: OrderTimeInForce) => { + return tif === OrderTimeInForce.FOK || tif === OrderTimeInForce.IOC; + }; + + // Default + let timeInForce = OrderTimeInForce.IOC; + + if (orderUpdate.timeInForce) { + if (isTifValid(orderUpdate.timeInForce)) { + timeInForce = orderUpdate.timeInForce; + } + } else { + if (isTifValid(curr.timeInForce)) { + timeInForce = curr.timeInForce; + } + } + + return { + type: orderUpdate.type, + size: orderUpdate.size || curr.size, + side: orderUpdate.side || curr.side, + timeInForce, + price: undefined, + expiration: undefined, + }; + } + + // Type is switching to limit so return new order object with correct defaults + if ( + orderUpdate.type === OrderType.Limit && + curr.type !== OrderType.Limit + ) { + return { + type: orderUpdate.type, + size: orderUpdate.size || curr.size, + side: orderUpdate.side || curr.side, + timeInForce: orderUpdate.timeInForce || curr.timeInForce, + price: orderUpdate.price || '0', + expiration: orderUpdate.expiration || undefined, + }; + } + + return { + ...curr, + ...orderUpdate, + }; + }); + }, []); + + return [order, updateOrder]; +}; diff --git a/libs/deal-ticket/tsconfig.json b/libs/deal-ticket/tsconfig.json new file mode 100644 index 000000000..4c089585e --- /dev/null +++ b/libs/deal-ticket/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/deal-ticket/tsconfig.lib.json b/libs/deal-ticket/tsconfig.lib.json new file mode 100644 index 000000000..252904bb7 --- /dev/null +++ b/libs/deal-ticket/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "files": [ + "../../node_modules/@nrwl/react/typings/cssmodule.d.ts", + "../../node_modules/@nrwl/react/typings/image.d.ts" + ], + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] +} diff --git a/libs/deal-ticket/tsconfig.spec.json b/libs/deal-ticket/tsconfig.spec.json new file mode 100644 index 000000000..67f149c4c --- /dev/null +++ b/libs/deal-ticket/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/libs/graphql/README.md b/libs/graphql/README.md new file mode 100644 index 000000000..90e4d61d8 --- /dev/null +++ b/libs/graphql/README.md @@ -0,0 +1,7 @@ +# graphql + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build graphql` to build the library. diff --git a/libs/graphql/package.json b/libs/graphql/package.json new file mode 100644 index 000000000..7b0a2b1d3 --- /dev/null +++ b/libs/graphql/package.json @@ -0,0 +1,5 @@ +{ + "name": "@vegaprotocol/graphql", + "version": "0.0.1", + "type": "commonjs" +} diff --git a/libs/graphql/project.json b/libs/graphql/project.json new file mode 100644 index 000000000..90fff2a0b --- /dev/null +++ b/libs/graphql/project.json @@ -0,0 +1,18 @@ +{ + "root": "libs/graphql", + "sourceRoot": "libs/graphql/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nrwl/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/graphql", + "main": "libs/graphql/src/index.ts", + "tsConfig": "libs/graphql/tsconfig.lib.json", + "assets": ["libs/graphql/*.md"] + } + } + }, + "tags": [] +} diff --git a/libs/graphql/src/index.ts b/libs/graphql/src/index.ts new file mode 100644 index 000000000..34e1ecfc8 --- /dev/null +++ b/libs/graphql/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/globalTypes'; +export * from './lib/Market'; +export * from './lib/Markets'; +export * from './lib/OrderEvent'; diff --git a/libs/graphql/src/lib/Market.ts b/libs/graphql/src/lib/Market.ts new file mode 100644 index 000000000..fbba42220 --- /dev/null +++ b/libs/graphql/src/lib/Market.ts @@ -0,0 +1,150 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { MarketState, MarketTradingMode } from './globalTypes'; + +// ==================================================== +// GraphQL query operation: Market +// ==================================================== + +export interface Market_market_tradableInstrument_instrument_product_settlementAsset { + __typename: 'Asset'; + /** + * The id of the asset + */ + id: string; + /** + * The symbol of the asset (e.g: GBP) + */ + symbol: string; + /** + * The full name of the asset (e.g: Great British Pound) + */ + name: string; +} + +export interface Market_market_tradableInstrument_instrument_product { + __typename: 'Future'; + /** + * String representing the quote (e.g. BTCUSD -> USD is quote) + */ + quoteName: string; + /** + * The name of the asset (string) + */ + settlementAsset: Market_market_tradableInstrument_instrument_product_settlementAsset; +} + +export interface Market_market_tradableInstrument_instrument { + __typename: 'Instrument'; + /** + * A reference to or instance of a fully specified product, including all required product parameters for that product (Product union) + */ + product: Market_market_tradableInstrument_instrument_product; +} + +export interface Market_market_tradableInstrument { + __typename: 'TradableInstrument'; + /** + * An instance of or reference to a fully specified instrument. + */ + instrument: Market_market_tradableInstrument_instrument; +} + +export interface Market_market_trades { + __typename: 'Trade'; + /** + * The hash of the trade data + */ + id: string; + /** + * The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64) + */ + price: string; + /** + * The number of contracts trades, will always be <= the remaining size of both orders immediately before the trade (uint64) + */ + size: string; + /** + * RFC3339Nano time for when the trade occurred + */ + createdAt: string; +} + +export interface Market_market_depth_lastTrade { + __typename: 'Trade'; + /** + * The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64) + */ + price: string; +} + +export interface Market_market_depth { + __typename: 'MarketDepth'; + /** + * Last trade for the given market (if available) + */ + lastTrade: Market_market_depth_lastTrade | null; +} + +export interface Market_market { + __typename: 'Market'; + /** + * Market ID + */ + id: string; + /** + * Market full name + */ + name: string; + /** + * decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct + * number denominated in the currency of the Market. (uint64) + * + * Examples: + * Currency Balance decimalPlaces Real Balance + * GBP 100 0 GBP 100 + * GBP 100 2 GBP 1.00 + * GBP 100 4 GBP 0.01 + * GBP 1 4 GBP 0.0001 ( 0.01p ) + * + * GBX (pence) 100 0 GBP 1.00 (100p ) + * GBX (pence) 100 2 GBP 0.01 ( 1p ) + * GBX (pence) 100 4 GBP 0.0001 ( 0.01p ) + * GBX (pence) 1 4 GBP 0.000001 ( 0.0001p) + */ + decimalPlaces: number; + /** + * Current state of the market + */ + state: MarketState; + /** + * Current mode of execution of the market + */ + tradingMode: MarketTradingMode; + /** + * An instance of or reference to a tradable instrument. + */ + tradableInstrument: Market_market_tradableInstrument; + /** + * Trades on a market + */ + trades: Market_market_trades[] | null; + /** + * Current depth on the order book for this market + */ + depth: Market_market_depth; +} + +export interface Market { + /** + * An instrument that is trading on the VEGA network + */ + market: Market_market | null; +} + +export interface MarketVariables { + marketId: string; +} diff --git a/apps/trading/pages/markets/__generated__/Markets.ts b/libs/graphql/src/lib/Markets.ts similarity index 95% rename from apps/trading/pages/markets/__generated__/Markets.ts rename to libs/graphql/src/lib/Markets.ts index 1b9efcdf5..52c4f04fd 100644 --- a/apps/trading/pages/markets/__generated__/Markets.ts +++ b/libs/graphql/src/lib/Markets.ts @@ -8,7 +8,7 @@ // ==================================================== export interface Markets_markets { - __typename: "Market"; + __typename: 'Market'; /** * Market ID */ diff --git a/libs/graphql/src/lib/OrderEvent.ts b/libs/graphql/src/lib/OrderEvent.ts new file mode 100644 index 000000000..33cabf251 --- /dev/null +++ b/libs/graphql/src/lib/OrderEvent.ts @@ -0,0 +1,122 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { + BusEventType, + OrderType, + OrderStatus, + OrderRejectionReason, +} from './globalTypes'; + +// ==================================================== +// GraphQL subscription operation: OrderEvent +// ==================================================== + +export interface OrderEvent_busEvents_event_TimeUpdate { + __typename: + | 'TimeUpdate' + | 'MarketEvent' + | 'TransferResponses' + | 'PositionResolution' + | 'Trade' + | 'Account' + | 'Party' + | 'MarginLevels' + | 'Proposal' + | 'Vote' + | 'MarketData' + | 'NodeSignature' + | 'LossSocialization' + | 'SettlePosition' + | 'Market' + | 'Asset' + | 'MarketTick' + | 'SettleDistressed' + | 'AuctionEvent' + | 'RiskFactor' + | 'Deposit' + | 'Withdrawal' + | 'OracleSpec' + | 'LiquidityProvision'; +} + +export interface OrderEvent_busEvents_event_Order_market { + __typename: 'Market'; + /** + * Market full name + */ + name: string; +} + +export interface OrderEvent_busEvents_event_Order { + __typename: 'Order'; + /** + * Type the order type (defaults to PARTY) + */ + type: OrderType | null; + /** + * Hash of the order data + */ + id: string; + /** + * The status of an order, for example 'Active' + */ + status: OrderStatus; + /** + * Reason for the order to be rejected + */ + rejectionReason: OrderRejectionReason | null; + /** + * RFC3339Nano formatted date and time for when the order was created (timestamp) + */ + createdAt: string; + /** + * Total number of contracts that may be bought or sold (immutable) (uint64) + */ + size: string; + /** + * The worst price the order will trade at (e.g. buy for price or less, sell for price or more) (uint64) + */ + price: string; + /** + * The market the order is trading on (probably stored internally as a hash of the market details) + */ + market: OrderEvent_busEvents_event_Order_market | null; +} + +export type OrderEvent_busEvents_event = + | OrderEvent_busEvents_event_TimeUpdate + | OrderEvent_busEvents_event_Order; + +export interface OrderEvent_busEvents { + __typename: 'BusEvent'; + /** + * the id for this event + */ + eventId: string; + /** + * the block hash + */ + block: string; + /** + * the type of event we're dealing with + */ + type: BusEventType; + /** + * the payload - the wrapped event + */ + event: OrderEvent_busEvents_event; +} + +export interface OrderEvent { + /** + * Subscribe to event data from the event bus + */ + busEvents: OrderEvent_busEvents[] | null; +} + +export interface OrderEventVariables { + partyId: string; +} diff --git a/libs/graphql/src/lib/globalTypes.ts b/libs/graphql/src/lib/globalTypes.ts new file mode 100644 index 000000000..7848b1dcd --- /dev/null +++ b/libs/graphql/src/lib/globalTypes.ts @@ -0,0 +1,137 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +//============================================================== +// START Enums and Input Objects +//============================================================== + +export enum BusEventType { + Account = 'Account', + Asset = 'Asset', + Auction = 'Auction', + Deposit = 'Deposit', + LiquidityProvision = 'LiquidityProvision', + LossSocialization = 'LossSocialization', + MarginLevels = 'MarginLevels', + Market = 'Market', + MarketCreated = 'MarketCreated', + MarketData = 'MarketData', + MarketTick = 'MarketTick', + MarketUpdated = 'MarketUpdated', + NodeSignature = 'NodeSignature', + OracleSpec = 'OracleSpec', + Order = 'Order', + Party = 'Party', + PositionResolution = 'PositionResolution', + Proposal = 'Proposal', + RiskFactor = 'RiskFactor', + SettleDistressed = 'SettleDistressed', + SettlePosition = 'SettlePosition', + TimeUpdate = 'TimeUpdate', + Trade = 'Trade', + TransferResponses = 'TransferResponses', + Vote = 'Vote', + Withdrawal = 'Withdrawal', +} + +/** + * The current state of a market + */ +export enum MarketState { + Active = 'Active', + Cancelled = 'Cancelled', + Closed = 'Closed', + Pending = 'Pending', + Proposed = 'Proposed', + Rejected = 'Rejected', + Settled = 'Settled', + Suspended = 'Suspended', + TradingTerminated = 'TradingTerminated', +} + +/** + * What market trading mode are we in + */ +export enum MarketTradingMode { + BatchAuction = 'BatchAuction', + Continuous = 'Continuous', + MonitoringAuction = 'MonitoringAuction', + OpeningAuction = 'OpeningAuction', +} + +/** + * Reason for the order being rejected by the core node + */ +export enum OrderRejectionReason { + AmendToGTTWithoutExpiryAt = 'AmendToGTTWithoutExpiryAt', + CannotAmendFromGFAOrGFN = 'CannotAmendFromGFAOrGFN', + CannotAmendPeggedOrderDetailsOnNonPeggedOrder = 'CannotAmendPeggedOrderDetailsOnNonPeggedOrder', + CannotAmendToFOKOrIOC = 'CannotAmendToFOKOrIOC', + CannotAmendToGFAOrGFN = 'CannotAmendToGFAOrGFN', + EditNotAllowed = 'EditNotAllowed', + ExpiryAtBeforeCreatedAt = 'ExpiryAtBeforeCreatedAt', + FOKOrderDuringAuction = 'FOKOrderDuringAuction', + GFAOrderDuringContinuousTrading = 'GFAOrderDuringContinuousTrading', + GFNOrderDuringAuction = 'GFNOrderDuringAuction', + GTCWithExpiryAtNotValid = 'GTCWithExpiryAtNotValid', + IOCOrderDuringAuction = 'IOCOrderDuringAuction', + InsufficientAssetBalance = 'InsufficientAssetBalance', + InsufficientFundsToPayFees = 'InsufficientFundsToPayFees', + InternalError = 'InternalError', + InvalidExpirationTime = 'InvalidExpirationTime', + InvalidMarketId = 'InvalidMarketId', + InvalidMarketType = 'InvalidMarketType', + InvalidOrderId = 'InvalidOrderId', + InvalidOrderReference = 'InvalidOrderReference', + InvalidPartyId = 'InvalidPartyId', + InvalidPersistence = 'InvalidPersistence', + InvalidRemainingSize = 'InvalidRemainingSize', + InvalidSize = 'InvalidSize', + InvalidTimeInForce = 'InvalidTimeInForce', + InvalidType = 'InvalidType', + MarginCheckFailed = 'MarginCheckFailed', + MarketClosed = 'MarketClosed', + MissingGeneralAccount = 'MissingGeneralAccount', + NonPersistentOrderExceedsPriceBounds = 'NonPersistentOrderExceedsPriceBounds', + OrderAmendFailure = 'OrderAmendFailure', + OrderNotFound = 'OrderNotFound', + OrderOutOfSequence = 'OrderOutOfSequence', + OrderRemovalFailure = 'OrderRemovalFailure', + PeggedOrderBuyCannotReferenceBestAskPrice = 'PeggedOrderBuyCannotReferenceBestAskPrice', + PeggedOrderMustBeGTTOrGTC = 'PeggedOrderMustBeGTTOrGTC', + PeggedOrderMustBeLimitOrder = 'PeggedOrderMustBeLimitOrder', + PeggedOrderOffsetMustBeGreaterOrEqualToZero = 'PeggedOrderOffsetMustBeGreaterOrEqualToZero', + PeggedOrderOffsetMustBeGreaterThanZero = 'PeggedOrderOffsetMustBeGreaterThanZero', + PeggedOrderSellCannotReferenceBestBidPrice = 'PeggedOrderSellCannotReferenceBestBidPrice', + PeggedOrderWithoutReferencePrice = 'PeggedOrderWithoutReferencePrice', + SelfTrading = 'SelfTrading', + TimeFailure = 'TimeFailure', + UnableToAmendPeggedOrderPrice = 'UnableToAmendPeggedOrderPrice', + UnableToRepricePeggedOrder = 'UnableToRepricePeggedOrder', +} + +/** + * Valid order statuses, these determine several states for an order that cannot be expressed with other fields in Order. + */ +export enum OrderStatus { + Active = 'Active', + Cancelled = 'Cancelled', + Expired = 'Expired', + Filled = 'Filled', + Parked = 'Parked', + PartiallyFilled = 'PartiallyFilled', + Rejected = 'Rejected', + Stopped = 'Stopped', +} + +export enum OrderType { + Limit = 'Limit', + Market = 'Market', + Network = 'Network', +} + +//============================================================== +// END Enums and Input Objects +//============================================================== diff --git a/libs/graphql/tsconfig.json b/libs/graphql/tsconfig.json new file mode 100644 index 000000000..696b638de --- /dev/null +++ b/libs/graphql/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/graphql/tsconfig.lib.json b/libs/graphql/tsconfig.lib.json new file mode 100644 index 000000000..a8b9431f9 --- /dev/null +++ b/libs/graphql/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [] + }, + "include": ["**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/libs/react-helpers/src/index.ts b/libs/react-helpers/src/index.ts index 61504ca1d..3a01e2b38 100644 --- a/libs/react-helpers/src/index.ts +++ b/libs/react-helpers/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/context'; export * from './lib/storage'; -export * from './lib/trading'; +export * from './lib/datetime'; +export * from './lib/decimals'; diff --git a/libs/react-helpers/src/lib/datetime/datetime.ts b/libs/react-helpers/src/lib/datetime/datetime.ts new file mode 100644 index 000000000..e792f8968 --- /dev/null +++ b/libs/react-helpers/src/lib/datetime/datetime.ts @@ -0,0 +1,13 @@ +/** Returns date in a format suitable for input[type=date] elements */ +export const formatForInput = (date: Date) => { + const padZero = (num: number) => num.toString().padStart(2, '0'); + + const year = date.getFullYear(); + const month = padZero(date.getMonth() + 1); + const day = padZero(date.getDate()); + const hours = padZero(date.getHours()); + const minutes = padZero(date.getMinutes()); + const secs = padZero(date.getSeconds()); + + return `${year}-${month}-${day}T${hours}:${minutes}:${secs}`; +}; diff --git a/libs/react-helpers/src/lib/datetime/index.ts b/libs/react-helpers/src/lib/datetime/index.ts new file mode 100644 index 000000000..bb1c9cca2 --- /dev/null +++ b/libs/react-helpers/src/lib/datetime/index.ts @@ -0,0 +1 @@ +export * from './datetime'; diff --git a/libs/react-helpers/src/lib/decimals/index.ts b/libs/react-helpers/src/lib/decimals/index.ts new file mode 100644 index 000000000..bedea9595 --- /dev/null +++ b/libs/react-helpers/src/lib/decimals/index.ts @@ -0,0 +1,12 @@ +import { BigNumber } from 'bignumber.js'; + +export function addDecimal(value: string, decimals: number): string { + if (!decimals) return value; + return new BigNumber(value || 0) + .dividedBy(Math.pow(10, decimals)) + .toFixed(decimals); +} +export function removeDecimal(value: string, decimals: number): string { + if (!decimals) return value; + return new BigNumber(value || 0).times(Math.pow(10, decimals)).toFixed(0); +} diff --git a/libs/react-helpers/src/lib/trading/index.tsx b/libs/react-helpers/src/lib/trading/index.tsx deleted file mode 100644 index ad460b543..000000000 --- a/libs/react-helpers/src/lib/trading/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export const Chart = () =>
TODO: Chart
; -export const Ticket = () =>
TODO: Ticket
; -export const Orderbook = () =>
TODO: Orderbook
; -export const Orders = () =>
TODO: Orders
; -export const Positions = () =>
TODO: Positions
; -export const Collateral = () =>
TODO: Collateral
; - -export type TradingView = keyof typeof TradingViews; - -export const TradingViews = { - chart: Chart, - ticket: Ticket, - orderbook: Orderbook, - orders: Orders, - positions: Positions, - collateral: Collateral, -}; diff --git a/libs/tailwindcss-config/src/theme.js b/libs/tailwindcss-config/src/theme.js index 4bc3462b1..d093df574 100644 --- a/libs/tailwindcss-config/src/theme.js +++ b/libs/tailwindcss-config/src/theme.js @@ -141,11 +141,9 @@ module.exports = { 'ui-small': ['10px', '16px'], }, - extend: { - boxShadow: { - callout: '5px 5px 0 1px rgba(255, 255, 255, 0.05)', - focus: '0px 0px 0px 1px #FFFFFF, 0px 0px 3px 2px #FFE600', - 'focus-dark': '0px 0px 0px 1px #000000, 0px 0px 3px 2px #FFE600', - }, + boxShadow: { + callout: '5px 5px 0 1px rgba(255, 255, 255, 0.05)', + focus: '0px 0px 0px 1px #FFFFFF, 0px 0px 3px 2px #FFE600', + 'focus-dark': '0px 0px 0px 1px #000000, 0px 0px 3px 2px #FFE600', }, }; diff --git a/libs/ui-toolkit/.storybook/preview.js b/libs/ui-toolkit/.storybook/preview.js index 6258bc80b..6d8a1840c 100644 --- a/libs/ui-toolkit/.storybook/preview.js +++ b/libs/ui-toolkit/.storybook/preview.js @@ -18,12 +18,22 @@ export const decorators = [
) : (
-
+ -
-
+ + -
+
), ]; + +const StoryWrapper = ({ children, className }) => ( +
+
+
+ {children} +
+
+
+); diff --git a/libs/ui-toolkit/src/components/button/button.tsx b/libs/ui-toolkit/src/components/button/button.tsx index 6402fa382..e53d0fef0 100644 --- a/libs/ui-toolkit/src/components/button/button.tsx +++ b/libs/ui-toolkit/src/components/button/button.tsx @@ -128,6 +128,7 @@ export const Button = forwardRef( ( { variant = 'primary', + type = 'button', children, className, prependIconName, @@ -137,7 +138,12 @@ export const Button = forwardRef( ref ) => { return ( - ); diff --git a/libs/ui-toolkit/src/components/callout/callout.stories.tsx b/libs/ui-toolkit/src/components/callout/callout.stories.tsx index 1c5da802e..249c84ea1 100644 --- a/libs/ui-toolkit/src/components/callout/callout.stories.tsx +++ b/libs/ui-toolkit/src/components/callout/callout.stories.tsx @@ -3,6 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'; import { Callout } from './callout'; import { Button } from '../button'; +import { Intent } from '../../utils/intent'; export default { title: 'Callout', @@ -20,43 +21,43 @@ Default.args = { export const Danger = Template.bind({}); Danger.args = { - intent: 'danger', + intent: Intent.Danger, children: 'Content', }; export const Warning = Template.bind({}); Warning.args = { - intent: 'warning', + intent: Intent.Warning, children: 'Content', }; export const Prompt = Template.bind({}); Prompt.args = { - intent: 'prompt', + intent: Intent.Prompt, children: 'Content', }; export const Progress = Template.bind({}); Progress.args = { - intent: 'progress', + intent: Intent.Progress, children: 'Content', }; export const Success = Template.bind({}); Success.args = { - intent: 'success', + intent: Intent.Success, children: 'Content', }; export const Help = Template.bind({}); Help.args = { - intent: 'help', + intent: Intent.Help, children: 'Content', }; export const IconAndContent = Template.bind({}); IconAndContent.args = { - intent: 'help', + intent: Intent.Help, title: 'This is what this thing does', iconName: 'endorsed', children: ( diff --git a/libs/ui-toolkit/src/components/callout/callout.test.tsx b/libs/ui-toolkit/src/components/callout/callout.test.tsx index 561052ee1..303f9f0bd 100644 --- a/libs/ui-toolkit/src/components/callout/callout.test.tsx +++ b/libs/ui-toolkit/src/components/callout/callout.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Callout } from '.'; +import { Intent } from '../../utils/intent'; test('It renders content within callout', () => { render(Content); @@ -15,25 +16,17 @@ test('It renders title and icon', () => { expect(screen.getByText('title')).toBeInTheDocument(); }); -const intents = ['danger', 'warning', 'prompt', 'success', 'help'] as [ - 'danger', - 'warning', - 'prompt', - 'success', - 'help' -]; +const intents = Object.values(Intent).filter((i) => i !== Intent.Progress); -intents.map((intent) => - test(`Applies class for ${intent}`, () => { - render(); - expect(screen.getByTestId('callout')).toHaveClass( - `shadow-intent-${intent}` - ); - }) -); +test.each(intents)('Applies class for %s', (intent) => { + render(); + expect(screen.getByTestId('callout')).toHaveClass( + `shadow-intent-${intent.toLowerCase()}` + ); +}); test(`Applies class for progress`, () => { - render(); + render(); expect(screen.getByTestId('callout')).toHaveClass( 'shadow-black', 'dark:shadow-white' diff --git a/libs/ui-toolkit/src/components/callout/callout.tsx b/libs/ui-toolkit/src/components/callout/callout.tsx index 815e44e45..ef25176a0 100644 --- a/libs/ui-toolkit/src/components/callout/callout.tsx +++ b/libs/ui-toolkit/src/components/callout/callout.tsx @@ -1,10 +1,11 @@ import classNames from 'classnames'; +import { getIntentShadow, Intent } from '../../utils/intent'; import { Icon, IconName } from '../icon'; export interface CalloutProps { children?: React.ReactNode; title?: React.ReactElement | string; - intent?: 'danger' | 'warning' | 'prompt' | 'progress' | 'success' | 'help'; + intent?: Intent; iconName?: IconName; headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; } @@ -12,25 +13,19 @@ export interface CalloutProps { export function Callout({ children, title, - intent = 'help', + intent = Intent.Help, iconName, headingLevel, }: CalloutProps) { const className = classNames( - 'shadow-callout', 'border', 'border-black', 'dark:border-white', 'text-body-large', 'dark:text-white', 'p-8', + getIntentShadow(intent), { - 'shadow-intent-danger': intent === 'danger', - 'shadow-intent-warning': intent === 'warning', - 'shadow-intent-prompt': intent === 'prompt', - 'shadow-black dark:shadow-white': intent === 'progress', - 'shadow-intent-success': intent === 'success', - 'shadow-intent-help': intent === 'help', flex: !!iconName, } ); diff --git a/libs/ui-toolkit/src/components/dialog/dialog.stories.tsx b/libs/ui-toolkit/src/components/dialog/dialog.stories.tsx new file mode 100644 index 000000000..97ec9cc31 --- /dev/null +++ b/libs/ui-toolkit/src/components/dialog/dialog.stories.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import { Dialog } from './dialog'; +import { Button } from '../button'; +import { Intent } from '../../utils/intent'; + +export default { + title: 'Dialog', + component: Dialog, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + const [open, setOpen] = useState(args.open); + return ( +
+ + +
+ ); +}; + +export const Default = Template.bind({}); +Default.args = { + open: false, + title: 'Title', + setOpen: () => undefined, + children:

Some content

, +}; + +export const Danger = Template.bind({}); +Danger.args = { + open: false, + title: 'Danger', + setOpen: () => undefined, + children:

Some content

, + intent: Intent.Danger, +}; + +export const Success = Template.bind({}); +Success.args = { + open: false, + title: 'Success', + setOpen: () => undefined, + children:

Some content

, + intent: Intent.Success, +}; + +export const Warning = Template.bind({}); +Warning.args = { + open: false, + title: 'Warning', + setOpen: () => undefined, + children:

Some content

, + intent: Intent.Warning, +}; diff --git a/libs/ui-toolkit/src/components/dialog/dialog.tsx b/libs/ui-toolkit/src/components/dialog/dialog.tsx new file mode 100644 index 000000000..79727c523 --- /dev/null +++ b/libs/ui-toolkit/src/components/dialog/dialog.tsx @@ -0,0 +1,43 @@ +import * as DialogPrimitives from '@radix-ui/react-dialog'; +import classNames from 'classnames'; +import { ReactNode } from 'react'; +import { getIntentShadow, Intent } from '../../utils/intent'; +import { Icon } from '../icon'; + +interface DialogProps { + children: ReactNode; + open: boolean; + onChange: (isOpen: boolean) => void; + title?: string; + intent?: Intent; +} + +export function Dialog({ + children, + open, + onChange, + title, + intent, +}: DialogProps) { + const contentClasses = classNames( + // Positions the modal in the center of screen + 'fixed w-[520px] px-28 py-24 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]', + // Need to apply background and text colors again as content is rendered in a portal + 'dark:bg-black dark:text-white-95 bg-white text-black-95', + getIntentShadow(intent) + ); + return ( + onChange(x)}> + + + + + + + {title &&

{title}

} + {children} +
+
+
+ ); +} diff --git a/libs/ui-toolkit/src/components/dialog/index.ts b/libs/ui-toolkit/src/components/dialog/index.ts new file mode 100644 index 000000000..20da8e550 --- /dev/null +++ b/libs/ui-toolkit/src/components/dialog/index.ts @@ -0,0 +1 @@ +export * from './dialog'; diff --git a/libs/ui-toolkit/src/components/dialog/index.tsx b/libs/ui-toolkit/src/components/dialog/index.tsx deleted file mode 100644 index a966995b8..000000000 --- a/libs/ui-toolkit/src/components/dialog/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as DialogPrimitives from '@radix-ui/react-dialog'; -import { ReactNode } from 'react'; - -interface DialogProps { - children: ReactNode; - open: boolean; - setOpen: (isOpen: boolean) => void; - title?: string; -} - -export function Dialog({ children, open, setOpen, title }: DialogProps) { - return ( - setOpen(x)}> - - - - {title &&

{title}

} - {children} -
-
-
- ); -} diff --git a/libs/ui-toolkit/src/components/form-group/index.tsx b/libs/ui-toolkit/src/components/form-group/index.tsx index 72972b6aa..2a7d13074 100644 --- a/libs/ui-toolkit/src/components/form-group/index.tsx +++ b/libs/ui-toolkit/src/components/form-group/index.tsx @@ -1,16 +1,26 @@ +import classNames from 'classnames'; import { ReactNode } from 'react'; interface FormGroupProps { children: ReactNode; - label: string; - labelFor: string; + label?: string; + labelFor?: string; + labelAlign?: 'left' | 'right'; } -export const FormGroup = ({ children, label, labelFor }: FormGroupProps) => { +export const FormGroup = ({ + children, + label, + labelFor, + labelAlign = 'left', +}: FormGroupProps) => { + const labelClasses = classNames('block text-ui mb-4', { + 'text-right': labelAlign === 'right', + }); return ( -
+
{label && ( -