diff --git a/public/bin-white.svg b/public/bin-white.svg new file mode 100644 index 00000000..71033e71 --- /dev/null +++ b/public/bin-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/bin.svg b/public/bin.svg new file mode 100644 index 00000000..f2ab18a9 --- /dev/null +++ b/public/bin.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/AccountHistory.tsx b/src/app/components/AccountHistory.tsx index d030a6ea..aa634525 100644 --- a/src/app/components/AccountHistory.tsx +++ b/src/app/components/AccountHistory.tsx @@ -1,24 +1,33 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAppDispatch, useAppSelector, useTranslations } from "hooks"; +import { DexterToast } from "./DexterToaster"; +import { PairInfo } from "alphadex-sdk-js/lib/models/pair-info"; +import "../styles/table.css"; import { Tables, fetchAccountHistory, selectOpenOrders, setSelectedTable, + selectOrderToCancel, + deselectOrderToCancel, cancelOrder, selectOrderHistory, AccountHistoryState, + Order, + batchCancel, } from "../state/accountHistorySlice"; +import { + getOrderIdentifier, + getOrderIdentifierFromAdex, +} from "./AccountHistoryUtils"; import { displayTime, - calculateTotalFees, - calculateAvgFilled, getPriceSymbol, displayOrderSide, + calculateAvgFilled, + calculateTotalFees, } from "../utils"; -import { PairInfo } from "alphadex-sdk-js/lib/models/pair-info"; - function createOrderReceiptAddressLookup( pairsList: PairInfo[] ): Record { @@ -86,7 +95,7 @@ export function AccountHistory() { }, [dispatch, account, pairAddress]); return ( -
+
@@ -99,9 +108,6 @@ interface TableProps { data: AccountHistoryState["orderHistory"]; } -import "../styles/table.css"; -import { DexterToast } from "./DexterToaster"; - // The headers refer to keys specified in // src/app/state/locales/{languagecode}/trade.json const headers = { @@ -115,7 +121,7 @@ const headers = { "order_price", "filled_qty", "completed_perc", - "action", + "cancel_orders", ], [Tables.ORDER_HISTORY]: [ "pair", @@ -133,10 +139,13 @@ const headers = { ], }; +// TODO: rename "order" to "adexOrderReceipt" function ActionButton({ order, + visible, }: { order: AccountHistoryState["orderHistory"][0]; + visible: boolean; }) { const t = useTranslations(); const dispatch = useAppDispatch(); @@ -171,9 +180,11 @@ function ActionButton({ t("failed_to_cancel_order") // error message ); }} - className="text-error hover:underline transition" + className={`text-error hover:underline transition ${ + !visible ? "invisible" : "" + }`} > - {t("cancel")} + {t("cancel_single_order")} ); } @@ -214,10 +225,14 @@ function DisplayTable() {
- + {tableToShow.headers.map((header, i) => ( ))} @@ -228,49 +243,110 @@ function DisplayTable() { ); } +const CancelOrdersHeaderRow = () => { + const t = useTranslations(); + const dispatch = useAppDispatch(); + const { isConnected } = useAppSelector((state) => state.radix); + const { selectedOrdersToCancel } = useAppSelector( + (state) => state.accountHistory + ); + const nbrOfOrders: number = Object.keys(selectedOrdersToCancel).length; + const orderSelected: boolean = nbrOfOrders > 0; + + return ( +
+ {orderSelected ? ( +
{ + if (!isConnected) { + alert(t("connect_wallet_to_batch_delete")); + return; + } + e.stopPropagation(); + DexterToast.promise( + // Function input, with following state-to-toast mapping + // -> pending: loading toast + // -> rejceted: error toast + // -> resolved: success toast + async () => { + const action = await dispatch( + batchCancel(Object.values(selectedOrdersToCancel)) + ); + if (!action.type.endsWith("fulfilled")) { + // Transaction was not fulfilled (e.g. userRejected or userCanceled) + throw new Error("Transaction failed due to user action."); + } + }, + t("submitting_batch_cancel"), // Loading message + t("cancelled"), // success message + t("failed_to_cancel_orders") // error message + ); + }} + > + + {nbrOfOrders > 1 + ? t("cancel_n_orders").replace( + "<$NBR_OF_ORDERS>", + nbrOfOrders.toString() + ) + : t("cancel_1_order")} + + trash can icon +
+ ) : ( +
+ {t("cancel_orders")} + trash can icon +
+ )} +
+ ); +}; + +const CheckBox = ({ order }: { order: Order }) => { + const dispatch = useAppDispatch(); + const { selectedOrdersToCancel } = useAppSelector( + (state) => state.accountHistory + ); + + const selectOrDeselectCheckbox = ( + event: React.MouseEvent, + n: number + ) => { + if (event.currentTarget.checked) { + if (n >= 8) { + alert("Cannot select more than 8 orders."); + event.preventDefault(); + } else { + dispatch(selectOrderToCancel(order)); + } + } else { + dispatch(deselectOrderToCancel(order)); + } + }; + + return ( + { + selectOrDeselectCheckbox(e, Object.keys(selectedOrdersToCancel).length); + }} + /> + ); +}; + const OpenOrdersRows = ({ data }: TableProps) => { const t = useTranslations(); - // Needed to create order NFT urls - const { pairsList } = useAppSelector((state) => state.rewardSlice); - const orderReceiptAddressLookup = createOrderReceiptAddressLookup(pairsList); return data.length ? ( - data.map((order) => ( - - - - - - - - - - - - + data.map((adexOrderReceipt, indx) => ( + )) ) : ( @@ -279,70 +355,188 @@ const OpenOrdersRows = ({ data }: TableProps) => { ); }; -const OrderHistoryRows = ({ data }: TableProps) => { +const OpenOrderRow = ({ adexOrderReceipt }: { adexOrderReceipt: any }) => { const t = useTranslations(); - // Needed to create order NFT urls const { pairsList } = useAppSelector((state) => state.rewardSlice); + const { selectedOrdersToCancel } = useAppSelector( + (state) => state.accountHistory + ); const orderReceiptAddressLookup = createOrderReceiptAddressLookup(pairsList); + const [rowIsHovered, setRowIsHovered] = useState(false); + const rowRef = useRef(null); + + useEffect(() => { + const handleMouseEnter = () => setRowIsHovered(true); + const handleMouseLeave = () => setRowIsHovered(false); + const rowElement = rowRef.current; + if (rowElement) { + rowElement.addEventListener("mouseenter", handleMouseEnter); + rowElement.addEventListener("mouseleave", handleMouseLeave); + } + // Cleanup the event listeners on unmount + return () => { + if (rowElement) { + rowElement.removeEventListener("mouseenter", handleMouseEnter); + rowElement.removeEventListener("mouseleave", handleMouseLeave); + } + }; + }, []); + const order: Order = { + pairAddress: adexOrderReceipt.pairAddress, + orderReceiptId: adexOrderReceipt.id, + orderReceiptAddress: + createOrderReceiptAddressLookup(pairsList)[adexOrderReceipt.pairAddress], + }; + const notSelected = + selectedOrdersToCancel[getOrderIdentifierFromAdex(adexOrderReceipt)] === + undefined; + + return ( + + + + + + + + + + + + + ); +}; + +const OrderHistoryRows = ({ data }: TableProps) => { + const t = useTranslations(); // Sort by timeCompleted data = data.sort((a, b) => b.timeCompleted.localeCompare(a.timeCompleted)); return data.length ? ( - data.map((order) => ( - ) + ) : ( + + + + ); +}; + +const OrderHistoryRow = ({ order }: { order: any }) => { + const t = useTranslations(); + // Needed to create order NFT urls + const { pairsList } = useAppSelector((state) => state.rewardSlice); + const orderReceiptAddressLookup = createOrderReceiptAddressLookup(pairsList); + const [rowIsHovered, setRowIsHovered] = useState(false); + const rowRef = useRef(null); + + useEffect(() => { + const handleMouseEnter = () => setRowIsHovered(true); + const handleMouseLeave = () => setRowIsHovered(false); + const rowElement = rowRef.current; + if (rowElement) { + rowElement.addEventListener("mouseenter", handleMouseEnter); + rowElement.addEventListener("mouseleave", handleMouseLeave); + } + // Cleanup the event listeners on unmount + return () => { + if (rowElement) { + rowElement.removeEventListener("mouseenter", handleMouseEnter); + rowElement.removeEventListener("mouseleave", handleMouseLeave); + } + }; + }, []); + + return ( + - - - - - - - - - - - - - - )) - ) : ( - - + `} + ref={rowRef} + > + + + + + + + + + + + + ); }; diff --git a/src/app/components/AccountHistoryUtils.ts b/src/app/components/AccountHistoryUtils.ts new file mode 100644 index 00000000..c1e70694 --- /dev/null +++ b/src/app/components/AccountHistoryUtils.ts @@ -0,0 +1,63 @@ +import { PairInfo } from "alphadex-sdk-js/lib/models/pair-info"; +import { Order } from "state/accountHistorySlice"; + +export function getOrderIdentifier(order: Order): string { + return constructOrderIdentifier(order.pairAddress, order.orderReceiptId); +} +export function getOrderIdentifierFromAdex(adexOrderReceipt: any): string { + return constructOrderIdentifier( + adexOrderReceipt.pairAddress, + adexOrderReceipt.id + ); +} +function constructOrderIdentifier(pairAddress: string, id: string) { + return `${pairAddress}_Order#${id}#`; +} + +export function createOrderReceiptAddressLookup( + pairsList: PairInfo[] +): Record { + const orderReceiptAddressLookup: Record = {}; + pairsList.forEach((pairInfo) => { + orderReceiptAddressLookup[pairInfo.address] = pairInfo.orderReceiptAddress; + }); + return orderReceiptAddressLookup; +} + +export function getBatchCancelManifest({ + userAccount, + orders, +}: { + userAccount: string; + orders: Order[]; +}) { + let manifest = orders + .map(({ pairAddress, orderReceiptAddress, orderReceiptId }, indx) => { + return ` + CALL_METHOD + Address("${userAccount}") + "create_proof_of_non_fungibles" + Address("${orderReceiptAddress}") + Array( + NonFungibleLocalId("#${orderReceiptId}#") + ) + ; + POP_FROM_AUTH_ZONE + Proof("proof${indx + 1}") + ; + CALL_METHOD + Address("${pairAddress}") + "cancel_order" + Proof("proof${indx + 1}") + ;`; + }) + .join("\n"); + manifest += ` + CALL_METHOD + Address("${userAccount}") + "deposit_batch" + Expression("ENTIRE_WORKTOP") + ; + `; + return manifest; +} diff --git a/src/app/state/accountHistorySlice.ts b/src/app/state/accountHistorySlice.ts index 0eb8a2e4..0980f06e 100644 --- a/src/app/state/accountHistorySlice.ts +++ b/src/app/state/accountHistorySlice.ts @@ -7,18 +7,30 @@ import { import { RootState, AppDispatch } from "./store"; import * as adex from "alphadex-sdk-js"; import { SdkResult } from "alphadex-sdk-js/lib/models/sdk-result"; -import { getRdt, RDT } from "../subscriptions"; +import { getRdt, getRdtOrThrow, RDT } from "../subscriptions"; +import { + getBatchCancelManifest, + getOrderIdentifier, +} from "../components/AccountHistoryUtils"; // TYPES AND INTERFACES export enum Tables { OPEN_ORDERS = "OPEN_ORDERS", ORDER_HISTORY = "ORDER_HISTORY", } + +export interface Order { + pairAddress: string; + orderReceiptId: string; + orderReceiptAddress: string; +} + export interface AccountHistoryState { trades: adex.Trade[]; orderHistory: adex.OrderReceipt[]; selectedTable: Tables; tables: Tables[]; + selectedOrdersToCancel: Record; // the key is `${orderRecieptAddress}_${nftRecieptId}` } // INITIAL STATE @@ -27,6 +39,7 @@ const initialState: AccountHistoryState = { orderHistory: [], selectedTable: Tables.OPEN_ORDERS, tables: Object.values(Tables), + selectedOrdersToCancel: {}, }; // ASYNC THUNKS @@ -52,6 +65,32 @@ export const fetchAccountHistory = createAsyncThunk< } }); +export const batchCancel = createAsyncThunk< + Order[], // return value + Order[], // input + { state: RootState } +>("accountHistory/batchCancel", async (payload, thunkAPI) => { + const state = thunkAPI.getState(); + const orders: Order[] = payload; + const account = state.radix?.walletData.accounts[0]?.address || ""; + if (!account) { + return thunkAPI.rejectWithValue("Account missing"); + } + const rdt = getRdtOrThrow(); + const result = await rdt.walletApi.sendTransaction({ + transactionManifest: getBatchCancelManifest({ + userAccount: account, + orders: orders, + }), + version: 1, + }); + if (result.isErr()) { + throw new Error(`Problem with submitting tx. ${result.error.message}`); + } + thunkAPI.dispatch(fetchAccountHistory()); + return orders; +}); + export const cancelOrder = createAsyncThunk< SdkResult, { orderId: number; pairAddress: string }, @@ -118,17 +157,34 @@ export const accountHistorySlice = createSlice({ setSelectedTable: (state, action: PayloadAction) => { state.selectedTable = action.payload; }, + selectOrderToCancel: (state, action: PayloadAction) => { + const order = action.payload; + const orderIdentifier = getOrderIdentifier(order); + state.selectedOrdersToCancel[orderIdentifier] = order; + }, + deselectOrderToCancel: (state, action: PayloadAction) => { + const order = action.payload; + const orderIdentifier = getOrderIdentifier(order); + delete state.selectedOrdersToCancel[orderIdentifier]; + }, + resetSelectedOrdersToCancel: (state) => { + state.selectedOrdersToCancel = {}; + }, }, extraReducers: (builder) => { builder.addCase(fetchAccountHistory.fulfilled, (state, action) => { state.orderHistory = action.payload.data.orders; }); + builder.addCase(batchCancel.fulfilled, (state) => { + state.selectedOrdersToCancel = {}; + }); }, }); // SELECTORS -export const { setSelectedTable } = accountHistorySlice.actions; +export const { setSelectedTable, selectOrderToCancel, deselectOrderToCancel } = + accountHistorySlice.actions; // TODO: possibly remove, as this selector seems to not be used anywhere in the code export const selectFilteredData = createSelector( diff --git a/src/app/state/locales/en/trade.json b/src/app/state/locales/en/trade.json index fa6a8039..06281fec 100644 --- a/src/app/state/locales/en/trade.json +++ b/src/app/state/locales/en/trade.json @@ -79,5 +79,12 @@ "quantity": "Quantity", "available": "Available", "displayed_value_is_exact_at": "Displayed value is exact at quote time, may change on button press due market changes.", - "fees_are_paid_in_received": "Fees are paid in received currency and are estimates. Negative fee amounts represent rewards to earn. Total received amount already discounts fees." + "fees_are_paid_in_received": "Fees are paid in received currency and are estimates. Negative fee amounts represent rewards to earn. Total received amount already discounts fees.", + "cancel_orders": "Cancel Orders", + "cancel_single_order": "Cancel Single Order", + "cancel_n_orders": "Cancel <$NBR_OF_ORDERS> Orders", + "cancel_1_order": "Cancel 1 Order", + "submitting_batch_cancel": "Submitting batch cancel", + "failed_to_cancel_orders": "Failed to cancel orders", + "connect_wallet_to_batch_delete": "Connect wallet to batch delete" } diff --git a/src/app/state/locales/pt/trade.json b/src/app/state/locales/pt/trade.json index 1dad8d2e..103d8b7a 100644 --- a/src/app/state/locales/pt/trade.json +++ b/src/app/state/locales/pt/trade.json @@ -79,5 +79,12 @@ "quantity": "Quantidade", "available": "Disponível", "displayed_value_is_exact_at": "O valor exibido é exato no momento da cotação, podendo ser alterado ao pressionar o botão devido a mudanças no mercado.", - "fees_are_paid_in_received": "As taxas são pagas na moeda recebida e são estimativas. Valores negativos de taxas representam recompensas a receber. O valor total recebido já inclui o desconto das taxas." + "fees_are_paid_in_received": "As taxas são pagas na moeda recebida e são estimativas. Valores negativos de taxas representam recompensas a receber. O valor total recebido já inclui o desconto das taxas.", + "cancel_orders": "Cancelar Ordens", + "cancel_single_order": "Cancelar Ordem Única", + "cancel_n_orders": "Cancelar <$NBR_OF_ORDERS> Ordens", + "cancel_1_order": "Cancelar 1 Orden", + "submitting_batch_cancel": "Enviando cancelamento em lote", + "failed_to_cancel_orders": "Falha ao cancelar pedidos", + "connect_wallet_to_batch_delete": "Conectar carteira para exclusão em lote" } diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index 5646598f..5c1c8bb6 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -223,3 +223,19 @@ input[type="number"]::-ms-clear { "tradeHistory"; } } + +/* Hacky fix to replace border with padding on accountHistory table */ +/* Needed to fix table hover state */ +.account-history table tr, +.account-history table thead tr, +.account-history table td { + border: 0; +} +.account-history table tr td:first-child, +.account-history table thead tr th:first-child { + padding-left: 16px; +} +.account-history table tr td:last-child, +.account-history table thead tr th:last-child { + padding-right: 16px; +}
- {t(header)} + {header === "cancel_orders" ? ( + + ) : ( + t(header) + )}
{order.pairName} - - #{order.id} - - {t(order.orderType)} - {t(displayOrderSide(order.side).text)} - {displayTime(order.timeSubmitted, "full_without_seconds")} - {order.amount} {order.specifiedToken.symbol} - - {order.price} {getPriceSymbol(order)} - - {/* Filled Qty (compute with completedPerc to avoid using amountFilled) */} - {order.status === "COMPLETED" - ? order.amount - : (order.amount * order.completedPerc) / 100}{" "} - {order.specifiedToken.symbol} - {order.completedPerc}% - -
{adexOrderReceipt.pairName} + + #{adexOrderReceipt.id} + + {t(adexOrderReceipt.orderType)} + {t(displayOrderSide(adexOrderReceipt.side).text)} + + {displayTime(adexOrderReceipt.timeSubmitted, "full_without_seconds")} + + {adexOrderReceipt.amount} {adexOrderReceipt.specifiedToken.symbol} + + {adexOrderReceipt.price} {getPriceSymbol(adexOrderReceipt)} + + {/* Filled Qty (compute with completedPerc to avoid using amountFilled) */} + {adexOrderReceipt.status === "COMPLETED" + ? adexOrderReceipt.amount + : (adexOrderReceipt.amount * adexOrderReceipt.completedPerc) / + 100}{" "} + {adexOrderReceipt.specifiedToken.symbol} + {adexOrderReceipt.completedPerc}% +
+ + +
+
{t("no_order_history")}
{order.pairName} - - #{order.id} - - {t(order.orderType)} - {t(displayOrderSide(order.side).text)} - {t(order.status)} - {/* Filled Qty (computed with completedPerc to avoid using amountFilled) */} - {order.status === "COMPLETED" - ? order.amount - : (order.amount * order.completedPerc) / 100}{" "} - {order.specifiedToken.symbol} - - {/* Order Qty */} - {order.amount} {order.specifiedToken.symbol} - - {calculateAvgFilled(order.token1Filled, order.token2Filled)}{" "} - {getPriceSymbol(order)} - - {order.orderType === "MARKET" - ? "-" - : `${order.price} ${getPriceSymbol(order)}`} - - {calculateTotalFees(order)} {order.unclaimedToken.symbol} - {displayTime(order.timeSubmitted, "full_without_seconds")}{displayTime(order.timeCompleted, "full_without_seconds")}
{t("no_order_history")}{order.pairName} + + #{order.id} + + {t(order.orderType)} + {t(displayOrderSide(order.side).text)} + {t(order.status)} + {/* Filled Qty (computed with completedPerc to avoid using amountFilled) */} + {order.status === "COMPLETED" + ? order.amount + : (order.amount * order.completedPerc) / 100}{" "} + {order.specifiedToken.symbol} + + {/* Order Qty */} + {order.amount} {order.specifiedToken.symbol} + + {calculateAvgFilled(order.token1Filled, order.token2Filled)}{" "} + {getPriceSymbol(order)} + + {order.orderType === "MARKET" + ? "-" + : `${order.price} ${getPriceSymbol(order)}`} + + {calculateTotalFees(order)} {order.unclaimedToken.symbol} + {displayTime(order.timeSubmitted, "full_without_seconds")}{displayTime(order.timeCompleted, "full_without_seconds")}