diff --git a/src/api/operator/operatorApi.ts b/src/api/operator/operatorApi.ts index 44acf5aa0..d902b8141 100644 --- a/src/api/operator/operatorApi.ts +++ b/src/api/operator/operatorApi.ts @@ -193,16 +193,24 @@ export async function getOrder(params: GetOrderParams): Promise * - owner: address * - sellToken: address * - buyToken: address + * - minValidTo: number */ export async function getOrders(params: GetOrdersParams): Promise { const { networkId, ...searchParams } = params - const { owner, sellToken, buyToken } = searchParams + const { owner, sellToken, buyToken, minValidTo } = searchParams + const defaultValues = { + includeFullyExecuted: 'true', + includeInvalidated: 'true', + includeInsufficientBalance: 'true', + includePresignaturePending: 'true', + includeUnsupportedTokens: 'true', + } console.log( `[getOrders] Fetching orders on network ${networkId} with filters: owner=${owner} sellToken=${sellToken} buyToken=${buyToken}`, ) - const searchString = buildSearchString({ ...searchParams }) + const searchString = buildSearchString({ ...searchParams, ...defaultValues, minValidTo: String(minValidTo) }) const queryString = '/orders/' + searchString diff --git a/src/api/operator/types.ts b/src/api/operator/types.ts index eb7ca6914..b72fbb359 100644 --- a/src/api/operator/types.ts +++ b/src/api/operator/types.ts @@ -119,7 +119,8 @@ export type GetOrderParams = WithNetworkId & { } export type GetOrdersParams = WithNetworkId & { - owner?: string + owner: string + minValidTo: number sellToken?: string buyToken?: string } diff --git a/src/apps/explorer/ExplorerApp.tsx b/src/apps/explorer/ExplorerApp.tsx index 8582292fb..d37c6eda4 100644 --- a/src/apps/explorer/ExplorerApp.tsx +++ b/src/apps/explorer/ExplorerApp.tsx @@ -118,7 +118,7 @@ const AppContent = (): JSX.Element => { } const Wrapper = styled.div` - max-width: 140rem; + max-width: 118rem; margin: 0 auto; ${media.mediumDown} { diff --git a/src/apps/explorer/components/OrdersTableWidget/OrdersTableWithData.tsx b/src/apps/explorer/components/OrdersTableWidget/OrdersTableWithData.tsx new file mode 100644 index 000000000..d050635b7 --- /dev/null +++ b/src/apps/explorer/components/OrdersTableWidget/OrdersTableWithData.tsx @@ -0,0 +1,19 @@ +import React, { useContext } from 'react' +import { faSpinner } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import OrdersTable from 'components/orders/OrdersUserDetailsTable' +import { EmptyItemWrapper } from 'components/common/StyledUserDetailsTable' +import { OrdersTableContext } from './context/OrdersTableContext' + +export const OrdersTableWithData: React.FC = () => { + const { orders, isFirstLoading } = useContext(OrdersTableContext) + + return isFirstLoading ? ( + + + + ) : ( + + ) +} diff --git a/src/apps/explorer/components/OrdersTableWidget/context/OrdersTableContext.tsx b/src/apps/explorer/components/OrdersTableWidget/context/OrdersTableContext.tsx new file mode 100644 index 000000000..13690b8aa --- /dev/null +++ b/src/apps/explorer/components/OrdersTableWidget/context/OrdersTableContext.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import { Order } from 'api/operator' + +interface CommonState { + orders: Order[] + error: string + isFirstLoading: boolean +} + +export const OrdersTableContext = React.createContext({} as CommonState) diff --git a/src/apps/explorer/components/OrdersTableWidget/index.tsx b/src/apps/explorer/components/OrdersTableWidget/index.tsx new file mode 100644 index 000000000..36aad2df1 --- /dev/null +++ b/src/apps/explorer/components/OrdersTableWidget/index.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react' +import styled from 'styled-components' +import { faSpinner } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import ExplorerTabs from 'apps/explorer/components/common/ExplorerTabs/ExplorerTab' +import { OrdersTableWithData } from './OrdersTableWithData' +import { OrdersTableContext } from './context/OrdersTableContext' +import { useGetOrders } from './useGetOrders' +import { TabItemInterface } from 'components/common/Tabs/Tabs' + +const StyledTabLoader = styled.span` + padding-left: 4px; +` + +const tabItems = (isLoadingOrders: boolean): TabItemInterface[] => { + return [ + { + id: 1, + tab: ( + <> + Orders + {isLoadingOrders && } + + ), + content: , + }, + ] +} + +interface Props { + ownerAddress: string +} + +const OrdersTableWidget: React.FC = ({ ownerAddress }) => { + const { orders, isLoading, error } = useGetOrders(ownerAddress) + + const isFirstLoading = useMemo(() => { + if (isLoading && orders.length === 0) return true + + return false + }, [isLoading, orders.length]) + + return ( + + + + ) +} + +export default OrdersTableWidget diff --git a/src/apps/explorer/components/OrdersTableWidget/useGetOrders.tsx b/src/apps/explorer/components/OrdersTableWidget/useGetOrders.tsx new file mode 100644 index 000000000..481c510b2 --- /dev/null +++ b/src/apps/explorer/components/OrdersTableWidget/useGetOrders.tsx @@ -0,0 +1,128 @@ +import { useState, useCallback, useEffect } from 'react' +import { subMinutes, getTime } from 'date-fns' + +import { Network } from 'types' +import { useMultipleErc20 } from 'hooks/useErc20' +import { getOrders, Order, RawOrder } from 'api/operator' +import { useNetworkId } from 'state/network' +import { transformOrder } from 'utils' +import { ORDERS_HISTORY_MINUTES_AGO, ORDERS_QUERY_INTERVAL } from 'apps/explorer/const' + +/** + * + * Merge new RawOrders consulted, that may have changed status + * + * @param previousOrders List of orders + * @param newOrdersFetched List of fetched block order that could have changed + */ +export function mergeNewOrders(previousOrders: Order[], newOrdersFetched: RawOrder[]): Order[] { + if (newOrdersFetched.length === 0) return previousOrders + + // find the order up to which it is to be replaced + const lastOrder = newOrdersFetched[newOrdersFetched.length - 1] + const positionLastOrder = previousOrders.findIndex((o) => o.uid === lastOrder.uid) + if (positionLastOrder === -1) { + return newOrdersFetched.map((order) => transformOrder(order)).concat(previousOrders) + } + + const slicedOrders: Order[] = previousOrders.slice(positionLastOrder + 1) + return newOrdersFetched.map((order) => transformOrder(order)).concat(slicedOrders) +} + +function isObjectEmpty(object: Record): boolean { + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + for (const key in object) { + if (key) return false + } + + return true +} + +type Result = { + orders: Order[] + error: string + isLoading: boolean +} + +export function useGetOrders(ownerAddress: string): Result { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [orders, setOrders] = useState([]) + const networkId = useNetworkId() || undefined + const [erc20Addresses, setErc20Addresses] = useState([]) + const { value: valueErc20s, isLoading: areErc20Loading } = useMultipleErc20({ networkId, addresses: erc20Addresses }) + const [mountNewOrders, setMountNewOrders] = useState(false) + + const fetchOrders = useCallback( + async (network: Network, owner: string, minTimeHistoryTimeStamp = 0): Promise => { + setIsLoading(true) + setError('') + + try { + const ordersFetched = await getOrders({ networkId: network, owner, minValidTo: minTimeHistoryTimeStamp }) + const newErc20Addresses = ordersFetched.reduce((accumulator: string[], element) => { + const updateAccumulator = (tokenAddress: string): void => { + if (accumulator.indexOf(tokenAddress) === -1) { + accumulator.push(tokenAddress) + } + } + updateAccumulator(element.buyToken) + updateAccumulator(element.sellToken) + + return accumulator + }, []) + + setErc20Addresses(newErc20Addresses) + // TODO -> For the moment it is neccesary to sort by date + ordersFetched.sort((a, b) => +new Date(b.creationDate) - +new Date(a.creationDate)) + + setOrders((previousOrders) => mergeNewOrders(previousOrders, ordersFetched)) + setMountNewOrders(true) + } catch (e) { + const msg = `Failed to fetch orders` + console.error(msg, e) + setError(msg) + } finally { + setIsLoading(false) + } + }, + [], + ) + + useEffect(() => { + if (!networkId) { + return + } + const getOrUpdateOrders = (minHistoryTime?: number): Promise => + fetchOrders(networkId, ownerAddress, minHistoryTime) + + getOrUpdateOrders() + + const intervalId: NodeJS.Timeout = setInterval(() => { + const minutesAgoTimestamp = getTime(subMinutes(new Date(), ORDERS_HISTORY_MINUTES_AGO)) + getOrUpdateOrders(Math.floor(minutesAgoTimestamp / 1000)) + }, ORDERS_QUERY_INTERVAL) + + return (): void => { + clearInterval(intervalId) + } + }, [fetchOrders, networkId, ownerAddress]) + + useEffect(() => { + if (areErc20Loading || isObjectEmpty(valueErc20s) || !mountNewOrders) { + return + } + + const newOrders = orders.map((order) => { + order.buyToken = valueErc20s[order.buyTokenAddress] || order.buyToken + order.sellToken = valueErc20s[order.sellTokenAddress] || order.sellToken + + return order + }) + + setOrders(newOrders) + setMountNewOrders(false) + }, [valueErc20s, networkId, areErc20Loading, mountNewOrders, orders]) + + return { orders, error, isLoading } +} diff --git a/src/apps/explorer/components/common/ExplorerTabs/ExplorerTab.tsx b/src/apps/explorer/components/common/ExplorerTabs/ExplorerTab.tsx index 1e1f1e1ae..cc3d621cc 100644 --- a/src/apps/explorer/components/common/ExplorerTabs/ExplorerTab.tsx +++ b/src/apps/explorer/components/common/ExplorerTabs/ExplorerTab.tsx @@ -11,6 +11,7 @@ const StyledTabs = styled.div` padding: 0; border: ${({ theme }): string => `1px solid ${theme.borderPrimary}`}; border-radius: 4px; + min-height: 33rem; > div > div.tablist { justify-content: flex-start; diff --git a/src/apps/explorer/const.ts b/src/apps/explorer/const.ts index b173428bf..54908c29b 100644 --- a/src/apps/explorer/const.ts +++ b/src/apps/explorer/const.ts @@ -2,6 +2,8 @@ import { AnalyticsDimension } from 'types' /** Explorer app constants */ export const ORDER_QUERY_INTERVAL = 10000 // in ms +export const ORDERS_QUERY_INTERVAL = 30000 // in ms +export const ORDERS_HISTORY_MINUTES_AGO = 10 // in minutes export const DISPLAY_TEXT_COPIED_CHECK = 1000 // in ms diff --git a/src/apps/explorer/pages/UserDetails.tsx b/src/apps/explorer/pages/UserDetails.tsx index 3372574ce..f7f07d997 100644 --- a/src/apps/explorer/pages/UserDetails.tsx +++ b/src/apps/explorer/pages/UserDetails.tsx @@ -1,5 +1,62 @@ import React from 'react' +import { useParams } from 'react-router' +import styled from 'styled-components' -const UserDetails: React.FC = () =>

Placeholder Page

+import { isAddress } from 'web3-utils' + +import { media } from 'theme/styles/media' +import NotFound from './NotFound' +import OrdersTableWidget from '../components/OrdersTableWidget' +import { useNetworkId } from 'state/network' +import { BlockExplorerLink } from 'components/common/BlockExplorerLink' +import { RowWithCopyButton } from 'components/common/RowWithCopyButton' + +const Wrapper = styled.div` + padding: 1.6rem; + margin: 0 auto; + width: 100%; + + ${media.mediumDown} { + max-width: 94rem; + } + + ${media.mobile} { + max-width: 100%; + } + > h1 { + display: flex; + padding: 2.4rem 0 2.35rem; + align-items: center; + font-weight: ${({ theme }): string => theme.fontBold}; + } +` +const TitleAddress = styled(RowWithCopyButton)` + font-size: ${({ theme }): string => theme.fontSizeDefault}; + font-weight: ${({ theme }): string => theme.fontNormal}; + margin: 0 0 0 1.5rem; + display: flex; + align-items: center; +` +const UserDetails: React.FC = () => { + const { address } = useParams<{ address: string }>() + const networkId = useNetworkId() || undefined + + if (!isAddress(address)) { + return + } else { + return ( + +

+ User details + } + /> +

+ +
+ ) + } +} export default UserDetails diff --git a/src/components/common/StyledUserDetailsTable.tsx b/src/components/common/StyledUserDetailsTable.tsx index 7b6d51688..d42dab6ca 100644 --- a/src/components/common/StyledUserDetailsTable.tsx +++ b/src/components/common/StyledUserDetailsTable.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components' import { SimpleTable, Props as SimpleTableProps } from 'components/common/SimpleTable' -interface Props { +export interface Props { showBorderTable?: boolean } @@ -11,6 +11,7 @@ export type StyledUserDetailsTableProps = SimpleTableProps & Props const StyledUserDetailsTable = styled(SimpleTable)` border: ${({ theme, showBorderTable }): string => (showBorderTable ? `0.1rem solid ${theme.borderPrimary}` : 'none')}; border-radius: 0.4rem; + margin-top: 0; tr td { &:not(:first-of-type) { @@ -38,10 +39,17 @@ const StyledUserDetailsTable = styled(SimpleTable)` gap: 6px; } + thead { + position: inherit; + } thead tr { width: 100%; } + tbody { + overflow: unset; + } + tbody tr:hover { backdrop-filter: contrast(0.9); } @@ -53,6 +61,18 @@ const StyledUserDetailsTable = styled(SimpleTable)` span.wrap-datedisplay > span:last-of-type { display: flex; } + + tbody tr td.row-td-empty { + grid-column: 1 / span all; + + :hover { + background-color: ${({ theme }): string => theme.bg1}; + } + } + + tbody tr.row-empty { + padding: 0; + } ` export const EmptyItemWrapper = styled.div` @@ -62,6 +82,8 @@ export const EmptyItemWrapper = styled.div` align-items: center; justify-content: center; display: flex; + width: 100%; + font-size: ${({ theme }): string => theme.fontSizeDefault}; ` export default StyledUserDetailsTable diff --git a/src/components/orders/OrdersUserDetailsTable/index.tsx b/src/components/orders/OrdersUserDetailsTable/index.tsx index 3da0174cd..95974f1ff 100644 --- a/src/components/orders/OrdersUserDetailsTable/index.tsx +++ b/src/components/orders/OrdersUserDetailsTable/index.tsx @@ -11,7 +11,7 @@ import { getOrderLimitPrice, formatCalculatedPriceToDisplay, formattedAmount } f import { StatusLabel } from '../StatusLabel' import { HelpTooltip } from 'components/Tooltip' import StyledUserDetailsTable, { - StyledUserDetailsTableProps, + Props as StyledUserDetailsTableProps, EmptyItemWrapper, } from '../../common/StyledUserDetailsTable' import Icon from 'components/Icon' @@ -20,10 +20,10 @@ import TradeOrderType from 'components/common/TradeOrderType' const Wrapper = styled(StyledUserDetailsTable)` > thead > tr, > tbody > tr { - grid-template-columns: 12rem 7rem repeat(2, 16rem) repeat(2, minmax(18rem, 24rem)) 1fr; + grid-template-columns: 12rem 7rem repeat(2, minmax(16rem, 1.5fr)) repeat(2, minmax(18rem, 2fr)) 1fr; } + overflow: auto; ` - function getLimitPrice(order: Order, isPriceInverted: boolean): string { if (!order.buyToken || !order.sellToken) return '-' @@ -59,9 +59,13 @@ const RowOrder: React.FC = ({ order, isPriceInverted }) => { { {shortId}} + contentsToDisplay={ + + {shortId} + + } /> } @@ -94,7 +98,14 @@ const OrdersUserDetailsTable: React.FC = (props) => { } const orderItems = (items: Order[]): JSX.Element => { - if (items.length === 0) return No Orders. + if (items.length === 0) + return ( + + + No Orders. + + + ) return ( <> diff --git a/src/hooks/useOperatorOrder.ts b/src/hooks/useOperatorOrder.ts index 8991f7a0b..5f671f2e3 100644 --- a/src/hooks/useOperatorOrder.ts +++ b/src/hooks/useOperatorOrder.ts @@ -60,7 +60,7 @@ async function _getOrder( } } -export function useOrderByNetwork(orderId: string, updateInterval = 0, networkId: Network | null): UseOrderResult { +export function useOrderByNetwork(orderId: string, networkId: Network | null, updateInterval = 0): UseOrderResult { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [order, setOrder] = useState(null) @@ -121,7 +121,7 @@ export function useOrderByNetwork(orderId: string, updateInterval = 0, networkId export function useOrder(orderId: string, updateInterval?: number): UseOrderResult { const networkId = useNetworkId() - return useOrderByNetwork(orderId, updateInterval, networkId) + return useOrderByNetwork(orderId, networkId, updateInterval) } type UseOrderAndErc20sResult = { diff --git a/src/utils/operator.ts b/src/utils/operator.ts index 226bf274b..c12fa4218 100644 --- a/src/utils/operator.ts +++ b/src/utils/operator.ts @@ -238,7 +238,7 @@ function isZeroAddress(address: string): boolean { } export function isTokenErc20(token: TokenErc20 | null | undefined): token is TokenErc20 { - return (token as TokenErc20).address !== undefined + return (token as TokenErc20)?.address !== undefined } export function formattedAmount(erc20: TokenErc20 | null | undefined, amount: BigNumber): string { diff --git a/test/utils/operator/orderFormatAmount.test.ts b/test/utils/operator/orderFormatAmount.test.ts new file mode 100644 index 000000000..d2bead2cd --- /dev/null +++ b/test/utils/operator/orderFormatAmount.test.ts @@ -0,0 +1,38 @@ +import BigNumber from 'bignumber.js' + +import { isTokenErc20, formattedAmount } from 'utils' + +import { TokenErc20 } from '@gnosis.pm/dex-js' + +const WEthToken: TokenErc20 = { + symbol: 'WETH', + name: 'Wrapped Ether', + address: '0xc778417e063141139fce010982780140aa0cd5ab', + decimals: 2, +} + +describe('Is token an ERC20', () => { + test('should return true when it complies with TokenERC20 interface', () => { + expect(isTokenErc20(WEthToken)).toBe(true) + }) + + test('should return false when object is undefined', () => { + const token = undefined + + expect(isTokenErc20(token)).toBe(false) + }) +}) + +describe('format amount', () => { + test('should return a string when input is a erc20 and amount', () => { + const amount = formattedAmount(WEthToken, new BigNumber('1')) + + expect(amount).toEqual('0.01') + }) + + test('should return dash(-) when erc20 is null', () => { + const amount = formattedAmount(null, new BigNumber('0.1')) + + expect(amount).toEqual('-') + }) +})