From 34f3eb93b273eaecc7b2c9c1171588fbbbd8a819 Mon Sep 17 00:00:00 2001 From: Dima Rifai <dima.rifai@gmail.com> Date: Fri, 13 Dec 2024 23:29:30 +0200 Subject: [PATCH] Issue 328 - New Files for Balance History --- .../balanceHistory/BalanceHistoryTable.tsx | 155 ++++++++++++++ .../home/searches/BalanceHistorySearch.tsx | 199 ++++++++++++++++++ hooks/api/balanceHistory/useBalanceHistory.ts | 58 +++++ pages/balanceHistory/[accountName].tsx | 160 ++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 components/balanceHistory/BalanceHistoryTable.tsx create mode 100644 components/home/searches/BalanceHistorySearch.tsx create mode 100644 hooks/api/balanceHistory/useBalanceHistory.ts create mode 100644 pages/balanceHistory/[accountName].tsx diff --git a/components/balanceHistory/BalanceHistoryTable.tsx b/components/balanceHistory/BalanceHistoryTable.tsx new file mode 100644 index 00000000..8a0e583d --- /dev/null +++ b/components/balanceHistory/BalanceHistoryTable.tsx @@ -0,0 +1,155 @@ +import Hive from "@/types/Hive"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import Link from "next/link"; +import React from "react"; +import { cn } from "@/lib/utils"; +import Explorer from "@/types/Explorer"; +import { getOperationTypeForDisplay } from "@/utils/UI"; +import { categorizedOperationTypes } from "@/utils/CategorizedOperationTypes"; +import { colorByOperationCategory } from "../OperationTypesDialog"; +import { useUserSettingsContext } from "@/contexts/UserSettingsContext"; +import TimeAgo from "timeago-react"; +import { formatAndDelocalizeTime } from "@/utils/TimeUtils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { useRouter } from "next/router"; +import useOperationsTypes from "@/hooks/api/common/useOperationsTypes"; +import { formatNumber } from "@/lib/utils"; +import CustomPagination from "../CustomPagination"; +import { config } from "@/Config"; + +interface OperationsTableProps { + operations: Explorer.BalanceHistoryForTable[]; + total_operations: number; + total_pages: number; + current_page: number; +} +const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ + operations, + total_operations, + total_pages, + current_page, +}) => { + const router = useRouter(); + const operationsTypes = useOperationsTypes().operationsTypes || []; + + const formatRawCoin = (coinValue: number) => + formatNumber(coinValue, false, false); + + const getOperationColor = (op_type_id: number) => { + const operation = operationsTypes.find( + (op) => op.op_type_id === op_type_id + ); + if (!operation) return ""; + const category = categorizedOperationTypes.find((cat) => + cat.types.includes(operation.operation_name) + ); + return category ? colorByOperationCategory[category.name] : ""; + }; + + const getOperationTypeForDisplayById = (op_type_id: number) => + getOperationTypeForDisplay( + operationsTypes.find((op) => op.op_type_id === op_type_id) + ?.operation_name || "" + ); + const updateUrl = (page: number) => { + router.push({ + pathname: router.pathname, + query: { ...router.query, page: page.toString() }, + }); + }; + + + return ( + <> + <CustomPagination + currentPage={current_page? current_page: 1} + onPageChange={updateUrl} + pageSize={config.standardPaginationSize} + totalCount={total_operations} + className="text-black dark:text-white" + isMirrored={false} + /> + {total_operations === 0 ? ( + <div className="flex justify-center w-full"> + No results matching given criteria + </div> + ) : ( + <Table className={cn("rounded-[6px] overflow-hidden max-w-[100%] text-xs mt-3")}> + <TableHeader> + <TableRow> + <TableHead>Operation Type</TableHead> + <TableHead>Date</TableHead> + <TableHead>Block Number</TableHead> + <TableHead>Balance</TableHead> + <TableHead>Balance Change</TableHead> + <TableHead>New Balance</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {operations.map((operation) => { + const operationBgColor = getOperationColor(operation.opTypeId); + const coinName = router.query.coinType?router.query.coinType: 'HIVE'; //defaults to HIVE + + return ( + <TableRow key={operation.operationId}> + <TableCell data-testid="operation-type"> + <div className="flex justify-stretch p-1 rounded"> + <span className={`rounded w-4 mr-2 ${operationBgColor}`}></span> + <span>{getOperationTypeForDisplayById(operation.opTypeId)}</span> + </div> + </TableCell> + <TableCell> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div> + <TimeAgo + datetime={new Date( + formatAndDelocalizeTime(operation.timestamp) + )} + /> + </div> + </TooltipTrigger> + <TooltipContent className="bg-theme text-text"> + {formatAndDelocalizeTime(operation.timestamp)} + </TooltipContent> + </Tooltip> + </TooltipProvider> + </TableCell> + <TableCell data-testid="block-number"> + <Link className="text-link" href={`/block/${operation.blockNumber}`}> + {operation.blockNumber?.toLocaleString()} + </Link> + </TableCell> + <TableCell data-testid="operation-prev-balance"> + {formatRawCoin(operation.prev_balance)} {coinName} + </TableCell> + <TableCell data-testid="operation-balance-change"> + {formatRawCoin(operation.balanceChange)} {coinName} + </TableCell> + <TableCell> + {formatRawCoin(operation.balance)} {coinName} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + )} + </> + ); +}; + +export default BalanceHistoryTable; \ No newline at end of file diff --git a/components/home/searches/BalanceHistorySearch.tsx b/components/home/searches/BalanceHistorySearch.tsx new file mode 100644 index 00000000..645e62a7 --- /dev/null +++ b/components/home/searches/BalanceHistorySearch.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import SearchRanges from "@/components/searchRanges/SearchRanges"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +import useSearchRanges from "@/hooks/common/useSearchRanges"; +import useURLParams from "@/hooks/common/useURLParams"; + +interface AccountSearchParams { + accountName?: string | undefined; + fromBlock: number | undefined; + toBlock: number | undefined; + fromDate: Date | undefined; + toDate: Date | undefined; + lastBlocks: number | undefined; + lastTime: number | undefined; + timeUnit: string | undefined; + rangeSelectKey: string | undefined; + page: number | undefined; + filters: boolean[]; + coinType?: string; +} + +const defaultSearchParams: AccountSearchParams = { + accountName: undefined, + fromBlock: undefined, + toBlock: undefined, + fromDate: undefined, + toDate: undefined, + lastBlocks: undefined, + lastTime: undefined, + timeUnit: "days", + rangeSelectKey: "none", + page: undefined, + filters: [], + coinType: "HIVE", // Default to HIVE +}; + +const BalanceHistorySearch = () => { + const [coinType, setCoinType] = useState<string>("HIVE"); // State to store the selected coin name + const COIN_TYPES = ["HIVE", "VESTS", "HBD"]; + const router = useRouter(); + const accountNameFromRoute = (router.query.accountName as string)?.slice(1); + + const { paramsState, setParams } = useURLParams( + { + ...defaultSearchParams, + }, + ["accountName"] + ); + + const { + filters: filtersParam, + fromBlock: fromBlockParam, + toBlock: toBlockParam, + fromDate: fromDateParam, + toDate: toDateParam, + lastBlocks: lastBlocksParam, + timeUnit: timeUnitParam, + lastTime: lastTimeParam, + rangeSelectKey, + page, + } = paramsState; + + const [initialSearch, setInitialSearch] = useState<boolean>(false); + const [filters, setFilters] = useState<boolean[]>([]); + + + const searchRanges = useSearchRanges(); + + const handleSearch = async (resetPage?: boolean) => { + if ( + !initialSearch && + (!!fromDateParam || + !!toDateParam || + !!fromBlockParam || + !!toBlockParam || + !!lastBlocksParam || + !!lastTimeParam || + !!filtersParam?.length) + ) { + fromDateParam && searchRanges.setStartDate(fromDateParam); + toDateParam && searchRanges.setEndDate(toDateParam); + fromBlockParam && searchRanges.setFromBlock(fromBlockParam); + toBlockParam && searchRanges.setToBlock(toBlockParam); + lastBlocksParam && searchRanges.setLastBlocksValue(lastBlocksParam); + timeUnitParam && searchRanges.setTimeUnitSelectKey(timeUnitParam); + rangeSelectKey && searchRanges.setRangeSelectKey(rangeSelectKey); + searchRanges.setLastTimeUnitValue(lastTimeParam); + setFilters(filtersParam); + setInitialSearch(true); + } else { + if (!initialSearch) { + setInitialSearch(true); + } + + const { + payloadFromBlock, + payloadToBlock, + payloadStartDate, + payloadEndDate, + } = await searchRanges.getRangesValues(); + + setParams({ + ...paramsState, + filters: filters, + fromBlock: payloadFromBlock, + toBlock: payloadToBlock, + fromDate: payloadStartDate, + toDate: payloadEndDate, + lastBlocks: + searchRanges.rangeSelectKey === "lastBlocks" + ? searchRanges.lastBlocksValue + : undefined, + lastTime: + searchRanges.rangeSelectKey === "lastTime" + ? searchRanges.lastTimeUnitValue + : undefined, + timeUnit: + searchRanges.rangeSelectKey === "lastTime" + ? searchRanges.timeUnitSelectKey + : undefined, + rangeSelectKey: searchRanges.rangeSelectKey, + page: resetPage ? undefined : page, + }); + } + }; + + const handleCoinTypeChange = (newCoinType: string) => { + setCoinType(newCoinType); + setParams({ ...paramsState, coinType: newCoinType }); + + }; + + const handleFilterClear = () => { + const newPage = rangeSelectKey !== "none" ? undefined : page; + setParams({ + ...defaultSearchParams, + accountName: accountNameFromRoute, + page: newPage, + }); + searchRanges.setRangeSelectKey("none"); + setFilters([]); + }; + + useEffect(() => { + if (paramsState.coinType) { + setCoinType(paramsState.coinType); + } + if (paramsState && !initialSearch) { + handleSearch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paramsState]); + + + return ( + <> + <p className="m-2 mb-6 mt-6"> + Find balance history of given account by coin and range. + </p> + + <Card className="mb-4"> + <CardHeader> + <CardTitle>Filters</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center mb-3"> + <div className="w-auto bg-theme dark:bg-theme border-0 border-b-2"> + <select + value={coinType} + onChange={(e) => handleCoinTypeChange(e.target.value)} + className="w-[180px] border border-gray-300 p-2 rounded" + > + {COIN_TYPES.map((type) => ( + <option key={type} value={type}> + {type} + </option> + ))} + </select> + </div> + </div> + <SearchRanges rangesProps={searchRanges} /> + <div className="flex items-center justify-between m-2"> + <Button onClick={() => handleSearch(true)} data-testid="apply-filters"> + <span>Apply filters</span> + </Button> + <Button onClick={() => handleFilterClear()} data-testid="clear-filters"> + <span>Clear filters</span> + </Button> + </div> + </CardContent> + </Card> + </> + ); +}; + +export default BalanceHistorySearch; diff --git a/hooks/api/balanceHistory/useBalanceHistory.ts b/hooks/api/balanceHistory/useBalanceHistory.ts new file mode 100644 index 00000000..c7fc0269 --- /dev/null +++ b/hooks/api/balanceHistory/useBalanceHistory.ts @@ -0,0 +1,58 @@ +import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import moment from "moment"; + +import fetchingService from "@/services/FetchingService"; + +const useBalanceHistory = ( + accountName: string, + coinType:string, + page: number | undefined, + pageSize : number | undefined, + direction: "asc" | "desc", + fromDate?: Date | number | undefined, + toDate?: Date| number |undefined, + +) => { + + /*const isDatesCorrect = + !moment(fromDate).isSame(toDate) && !moment(fromDate).isAfter(toDate);*/ + const isDatesCorrect =true; + const fetchBalanceHist = async () => + await fetchingService.geAccounttBalanceHistory( + accountName, + coinType, + page?page:1, + pageSize, + direction, + fromDate?fromDate:undefined, + toDate?toDate:undefined, + ); + + const { + data: accountBalanceHistory, + isLoading: isAccountBalanceHistoryLoading, + isError: isAccountBalanceHistoryError, + }: any = useQuery({ + queryKey: [ + "get_balance_history", + accountName, + coinType, + page, + pageSize, + direction, + fromDate, + toDate, + ], + queryFn: fetchBalanceHist, + enabled: !!accountName && isDatesCorrect, + refetchOnWindowFocus: false, + }); + return { + accountBalanceHistory, + isAccountBalanceHistoryLoading, + isAccountBalanceHistoryError, + }; + +}; + +export default useBalanceHistory; diff --git a/pages/balanceHistory/[accountName].tsx b/pages/balanceHistory/[accountName].tsx new file mode 100644 index 00000000..10cfdabf --- /dev/null +++ b/pages/balanceHistory/[accountName].tsx @@ -0,0 +1,160 @@ +import { useRouter } from "next/router"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; + +import { config } from "@/Config"; + +import { Loader2 } from "lucide-react"; + +import useBalanceHistory from "@/hooks/api/balanceHistory/useBalanceHistory"; +import useURLParams from "@/hooks/common/useURLParams"; + +import { convertBalanceHistoryResultsToTableOperations } from "@/lib/utils"; +import { getHiveAvatarUrl } from "@/utils/HiveBlogUtils"; + +import BalanceHistoryTable from "@/components/balanceHistory/BalanceHistoryTable"; +import BalanceHistorySearch from "@/components/home/searches/BalanceHistorySearch"; +import { Card, CardHeader } from "@/components/ui/card"; + +export default function BalanceHistory() { + const router = useRouter(); + const accountNameFromRoute = (router.query.accountName as string)?.slice(1); + + interface BalanceHistorySearchParams { + accountName?: string; + coinType: string; + fromBlock: Date | number | undefined; + toBlock: Date | number | undefined; + fromDate: undefined; + toDate: undefined; + lastBlocks: number | undefined; + lastTime: number | undefined; + timeUnit: string | undefined; + rangeSelectKey: string | undefined; + page: number; + filters: boolean[]; + } + + const defaultSearchParams: BalanceHistorySearchParams = { + accountName: accountNameFromRoute, + coinType: "HIVE", + fromBlock: undefined, + toBlock: undefined, + fromDate: undefined, + toDate: undefined, + lastBlocks: undefined, + lastTime: undefined, + timeUnit: "days", + rangeSelectKey: "none", + page: 1, + filters: [], + }; + + const { paramsState } = useURLParams(defaultSearchParams, ["accountName"]); + + const { + filters: filtersParam, + fromBlock: fromBlockParam, + toBlock: toBlockParam, + fromDate: fromDateParam, + toDate: toDateParam, + lastBlocks: lastBlocksParam, + timeUnit: timeUnitParam, + lastTime: lastTimeParam, + rangeSelectKey, + page, + } = paramsState; + + let effectiveFromBlock = paramsState.fromBlock || fromDateParam; + let effectiveToBlock = paramsState.toBlock || toDateParam; + + if ( + rangeSelectKey === "lastBlocks" && + typeof effectiveFromBlock === "number" && + paramsState.lastBlocks + ) { + effectiveToBlock = effectiveFromBlock + paramsState.lastBlocks; + } + + const { + accountBalanceHistory, + isAccountBalanceHistoryLoading, + isAccountBalanceHistoryError, + } = useBalanceHistory( + accountNameFromRoute, + paramsState.coinType, + paramsState.page, + config.standardPaginationSize, + "desc", + effectiveFromBlock, + effectiveToBlock + ); + + return ( + <> + <Head> + <title>@{accountNameFromRoute} - Hive Explorer</title> + </Head> + + <div className="w-[95%] overflow-auto"> + <Card data-testid="account-details"> + <CardHeader> + <div className="flex flex-wrap items-center justify-between gap-4 bg-theme dark:bg-theme"> + {/* Avatar and Name */} + <div className="flex items-center gap-4"> + <Image + className="rounded-full border-2 border-explorer-orange" + src={getHiveAvatarUrl(accountNameFromRoute)} + alt="avatar" + width={60} + height={60} + data-testid="user-avatar" + /> + <div> + <h2 + className="text-lg font-semibold text-gray-800 dark:text-white" + data-testid="account-name" + > + <Link + className="text-link" + href={`/@${accountNameFromRoute}`} + > + {" "} + {accountNameFromRoute} + </Link> + / <span className="text-text">Balance History</span> + </h2> + </div> + </div> + </div> + </CardHeader> + </Card> + + {/* Filter Options (Always visible) */} + <BalanceHistorySearch /> + + {/* Show Error Message if No Balance History and No Loading State */} + {!isAccountBalanceHistoryLoading && !accountBalanceHistory?.total_operations ? ( + <div className="w-full my-4 text-black text-center"> + No operations were found. + </div> + ) : isAccountBalanceHistoryLoading ? ( + <div className="flex justify-center text-center items-center"> + <Loader2 className="animate-spin mt-1 text-black h-12 w-12 ml-3" /> + </div> + ) : ( + // Show the table when balance history exists + <BalanceHistoryTable + operations={convertBalanceHistoryResultsToTableOperations( + accountBalanceHistory + )} + total_operations={accountBalanceHistory.total_operations} + total_pages={accountBalanceHistory.total_pages} + current_page={paramsState.page} + /> + )} + </div> + </> + ); +} -- GitLab