From 730dd47d37bc5908aee83678760f420d3533a4d6 Mon Sep 17 00:00:00 2001 From: Eugene P Date: Wed, 31 Jan 2024 18:21:14 +0200 Subject: [PATCH 1/4] Feat: Improve Address Transaction History export (#853) (#911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Improve Address Transaction History export (#853) Signed-off-by: Eugene Panteleymonchuk * chore: request output details. Signed-off-by: Eugene Panteleymonchuk * chore: calculate transactions. Signed-off-by: Eugene Panteleymonchuk * chore: export done. Signed-off-by: Eugene Panteleymonchuk * chore: simplify groupOutputsByTransactionId Signed-off-by: Eugene Panteleymonchuk * chore: simplify calculateBalanceChange func Signed-off-by: Eugene Panteleymonchuk * chore: update formatting function. Signed-off-by: Eugene Panteleymonchuk * fix: eslint, symbol if value zero. Signed-off-by: Eugene Panteleymonchuk * fix: eslint Signed-off-by: Eugene Panteleymonchuk * enhancement: rephrase texts --------- Signed-off-by: Eugene Panteleymonchuk Co-authored-by: Begoña Álvarez de la Cruz --- .../transactionhistory/download/post.ts | 197 +++++++++++++++++- .../app/components/stardust/DownloadModal.tsx | 4 +- 2 files changed, 190 insertions(+), 11 deletions(-) diff --git a/api/src/routes/stardust/transactionhistory/download/post.ts b/api/src/routes/stardust/transactionhistory/download/post.ts index 26ba0d6e5..355a9a7c8 100644 --- a/api/src/routes/stardust/transactionhistory/download/post.ts +++ b/api/src/routes/stardust/transactionhistory/download/post.ts @@ -1,14 +1,31 @@ +import { OutputResponse, INodeInfoBaseToken, CommonOutput } from "@iota/sdk"; import JSZip from "jszip"; +import moment from "moment"; import { ServiceFactory } from "../../../../factories/serviceFactory"; import logger from "../../../../logger"; import { IDataResponse } from "../../../../models/api/IDataResponse"; import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; +import { ITransactionHistoryItem } from "../../../../models/api/stardust/chronicle/ITransactionHistoryResponse"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { STARDUST } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; import { ChronicleService } from "../../../../services/stardust/chronicleService"; +import { NodeInfoService } from "../../../../services/stardust/nodeInfoService"; +import { StardustApiService } from "../../../../services/stardust/stardustApiService"; import { ValidationHelper } from "../../../../utils/validationHelper"; +export type OutputWithDetails = ITransactionHistoryItem & { details: OutputResponse | null; amount?: string }; + +export interface ITransactionHistoryRecord { + isGenesisByDate: boolean; + isSpent: boolean; + transactionId: string; + timestamp: number; + dateFormatted: string; + balanceChange: number; + balanceChangeFormatted: string; + outputs: OutputWithDetails[]; +} /** * Download the transaction history from chronicle stardust. @@ -23,28 +40,55 @@ export async function post( body: ITransactionHistoryDownloadBody, ): Promise { const networkService = ServiceFactory.get("network"); - const networks = networkService.networkNames(); - ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.oneOf(request.network, networkService.networkNames(), "network"); const networkConfig = networkService.get(request.network); - if (networkConfig.protocolVersion !== STARDUST) { - return null; - } - - if (!networkConfig.permaNodeEndpoint) { + const nodeInfoService = ServiceFactory.get(`node-info-${request.network}`); + if (networkConfig.protocolVersion !== STARDUST || !networkConfig.permaNodeEndpoint) { return null; } const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); + const outputs = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); + + const fulfilledOutputs: OutputWithDetails[] = await Promise.all( + outputs.items.map(async (output) => { + const details = await requestOutputDetails(output.outputId, networkConfig.network); + return { + ...output, + details, + amount: details?.output?.amount, + }; + }), + ); + + fulfilledOutputs.sort((a, b) => { + // Ensure that entries with equal timestamp, but different isSpent, + // have the spending before the depositing + if (a.milestoneTimestamp === b.milestoneTimestamp && a.isSpent !== b.isSpent) { + return a.isSpent ? 1 : -1; + } + return 1; + }); + const tokenInfo = nodeInfoService.getNodeInfo().baseToken; - const result = await chronicleService.transactionHistoryDownload(request.address, body.targetDate); + const transactions = getTransactionHistoryRecords(groupOutputsByTransactionId(fulfilledOutputs), tokenInfo); + + const headers = ["Timestamp", "TransactionId", "Balance changes"]; + + let csvContent = `${headers.join(",")}\n`; + + for (const transaction of transactions) { + const row = [transaction.dateFormatted, transaction.transactionId, transaction.balanceChangeFormatted].join(","); + csvContent += `${row}\n`; + } const jsZip = new JSZip(); let response: IDataResponse = null; try { - jsZip.file("history.json", JSON.stringify(result)); + jsZip.file("history.csv", csvContent); const content = await jsZip.generateAsync({ type: "nodebuffer" }); response = { @@ -57,3 +101,138 @@ export async function post( return response; } + +const requestOutputDetails = async (outputId: string, network: string): Promise => { + if (!outputId) { + return null; + } + + const apiService = ServiceFactory.get(`api-service-${network}`); + + try { + const response = await apiService.outputDetails(outputId); + const details = response.output; + + if (!response.error && details?.output && details?.metadata) { + return details; + } + return null; + } catch { + console.warn("Failed loading transaction history details!"); + return null; + } +}; + +export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetails[]) => { + const transactionIdToOutputs = new Map(); + for (const output of outputsWithDetails) { + const detailsMetadata = output?.details?.metadata; + const transactionId = output.isSpent ? detailsMetadata.transactionIdSpent : detailsMetadata.transactionId; + if (detailsMetadata && transactionId) { + const previousOutputs = transactionIdToOutputs.get(transactionId) || []; + transactionIdToOutputs.set(transactionId, [...previousOutputs, output]); + } + } + + return transactionIdToOutputs; +}; + +export const getTransactionHistoryRecords = ( + transactionIdToOutputs: Map, + tokenInfo: INodeInfoBaseToken, +): ITransactionHistoryRecord[] => { + const calculatedTransactions: ITransactionHistoryRecord[] = []; + + for (const [transactionId, outputs] of transactionIdToOutputs.entries()) { + const lastOutputTime = Math.max(...outputs.map((t) => t.milestoneTimestamp)); + const balanceChange = calculateBalanceChange(outputs); + + const isGenesisByDate = outputs.map((t) => t.milestoneTimestamp).includes(0); + + const isSpent = balanceChange <= 0; + + calculatedTransactions.push({ + isGenesisByDate, + isSpent, + transactionId, + timestamp: lastOutputTime, + dateFormatted: moment(lastOutputTime * 1000).format("YYYY-MM-DD HH:mm:ss"), + balanceChange, + balanceChangeFormatted: formatAmount(Math.abs(balanceChange), tokenInfo, false, isSpent), + outputs, + }); + } + return calculatedTransactions; +}; + +export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { + let totalAmount = 0; + + for (const output of outputs) { + const outputFromDetails = output?.details?.output as CommonOutput; + + // Perform the calculation only if outputFromDetails and amount are defined + if (outputFromDetails?.amount) { + let amount = Number(outputFromDetails.amount); + if (output.isSpent) { + amount *= -1; + } + totalAmount += amount; + } else { + console.warn("Output details not found for:", output); + } + } + + return totalAmount; +}; + +/** + * Formats a numeric value into a string using token information and specified formatting rules. + * + * @param value - The value to format. + * @param tokenInfo - Information about the token, including units and decimals. + * @param formatFull - If true, formats the entire number. Otherwise, uses decimalPlaces. + * @param isSpent - boolean indicating if the amount is spent or not. Need to provide right symbol. + * @returns The formatted amount with the token unit. + */ +export function formatAmount(value: number, tokenInfo: INodeInfoBaseToken, formatFull: boolean = false, isSpent: boolean = false): string { + let isSpentSymbol = isSpent ? "-" : "+"; + + if (!value) { + // 0 is not spent + isSpentSymbol = ""; + } + + if (formatFull) { + return `${isSpentSymbol}${value} ${tokenInfo.subunit ?? tokenInfo.unit}`; + } + + const baseTokenValue = value / Math.pow(10, tokenInfo.decimals); + const formattedAmount = cropNumber(baseTokenValue); + + return `${isSpentSymbol}${formattedAmount} ${tokenInfo.unit}`; +} + +/** + * Crops the fractional part of a number to 6 digits. + * @param value The value to crop. + * @param decimalPlaces - The number of decimal places to include in the formatted output. + * @returns The cropped value. + */ +function cropNumber(value: number, decimalPlaces: number = 6): string { + const valueAsString = value.toString(); + + if (!valueAsString.includes(".")) { + return valueAsString; + } + + const [integerPart, rawFractionalPart] = valueAsString.split("."); + let fractionalPart = rawFractionalPart; + + if (fractionalPart.length > decimalPlaces) { + fractionalPart = fractionalPart.slice(0, 6); + } + fractionalPart = fractionalPart.replace(/0+$/, ""); + + return fractionalPart ? `${integerPart}.${fractionalPart}` : integerPart; +} diff --git a/client/src/app/components/stardust/DownloadModal.tsx b/client/src/app/components/stardust/DownloadModal.tsx index 139e44657..a42f87c2e 100644 --- a/client/src/app/components/stardust/DownloadModal.tsx +++ b/client/src/app/components/stardust/DownloadModal.tsx @@ -12,7 +12,7 @@ interface DownloadModalProps { readonly address: string; } -const DOWNLOAD_INFO = "History will be downloaded from present date up to target date."; +const DOWNLOAD_INFO = "History will be downloaded from start date to present."; const DownloadModal: React.FC = ({ network, address }) => { const [showModal, setShowModal] = useState(false); @@ -58,7 +58,7 @@ const DownloadModal: React.FC = ({ network, address }) => {
-
Select target date
+
Select start date
info From f1553dbe9f6c9e1388144d86e523c5cd607ce868 Mon Sep 17 00:00:00 2001 From: JCNoguera <88061365+VmMad@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:38:16 +0100 Subject: [PATCH 2/4] feat: refactor navbar & add evm explorer links no navbar (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add evm explorer links to the header * fx: add missing extension to lint-staged * refactor: cleanup Header and create HeaderDropdown * refactor: simplify header component * fix: type checking * fix: move evm explorer next to Explorer * enhancement: polish CSS and rename evm navbar items --------- Co-authored-by: Begoña Alvarez --- client/src/app/App.tsx | 14 +- client/src/app/AppUtils.tsx | 70 ++- client/src/app/components/header/Header.scss | 28 +- client/src/app/components/header/Header.tsx | 473 +++++++----------- .../app/components/header/HeaderDropdown.tsx | 142 ++++++ .../src/app/components/header/HeaderProps.ts | 68 --- .../src/app/components/header/HeaderState.ts | 29 -- .../header/NavigationRouteHelper.tsx | 18 + client/src/app/lib/interfaces/index.ts | 1 + .../app/lib/interfaces/routes.interfaces.ts | 15 + 10 files changed, 434 insertions(+), 424 deletions(-) create mode 100644 client/src/app/components/header/HeaderDropdown.tsx delete mode 100644 client/src/app/components/header/HeaderProps.ts delete mode 100644 client/src/app/components/header/HeaderState.ts create mode 100644 client/src/app/components/header/NavigationRouteHelper.tsx create mode 100644 client/src/app/lib/interfaces/index.ts create mode 100644 client/src/app/lib/interfaces/routes.interfaces.ts diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index fbfcbdf0d..10ec286e4 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -3,12 +3,11 @@ import React, { useEffect, useState } from "react"; import { Helmet } from "react-helmet"; import { RouteComponentProps } from "react-router-dom"; import { AppRouteProps } from "./AppRouteProps"; -import { buildMetaLabel, buildUtilities, getFooterItems, getPages, getFaviconHelmet, networkContextWrapper } from "./AppUtils"; +import { buildMetaLabel, getFooterItems, getPages, getFaviconHelmet, networkContextWrapper } from "./AppUtils"; import Disclaimer from "./components/Disclaimer"; import Footer from "./components/footer/Footer"; import ShimmerFooter from "./components/footer/ShimmerFooter"; import Header from "./components/header/Header"; -import SearchInput from "./components/SearchInput"; import buildAppRoutes from "./routes"; import { ServiceFactory } from "~factories/serviceFactory"; import { isShimmerUiTheme } from "~helpers/networkHelper"; @@ -62,6 +61,7 @@ const App: React.FC> = ({ } const routes = buildAppRoutes(networkConfig?.protocolVersion ?? "", withNetworkContext); + const pages = getPages(networkConfig, networks); const metaLabel = buildMetaLabel(currentNetworkName); const faviconHelmet = getFaviconHelmet(isShimmer); @@ -81,14 +81,8 @@ const App: React.FC> = ({ networks={networks} action={action} history={history} - search={ - history.push(`/${currentNetworkName}/search/${query}`)} - protocolVersion={networkConfig?.protocolVersion ?? STARDUST} - /> - } - pages={getPages(networkConfig, networks)} - utilities={buildUtilities(network ?? "", networks, identityResolverEnabled)} + protocolVersion={protocolVersion} + pages={pages} />
{networks.length > 0 ? ( diff --git a/client/src/app/AppUtils.tsx b/client/src/app/AppUtils.tsx index b53930ab1..26fdab134 100644 --- a/client/src/app/AppUtils.tsx +++ b/client/src/app/AppUtils.tsx @@ -5,6 +5,7 @@ import { INetwork } from "~models/config/INetwork"; import { ALPHANET, CHRYSALIS_MAINNET, DEVNET, LEGACY_MAINNET, MAINNET, NetworkType, SHIMMER, TESTNET } from "~models/config/networkType"; import { IOTA_UI, Theme } from "~models/config/uiTheme"; import { IReducedNodeInfo } from "~services/nodeInfoService"; +import { NavigationRoute } from "./lib/interfaces"; export const networkContextWrapper = (currentNetwork: string | undefined, nodeInfo: IReducedNodeInfo | null, uiTheme: Theme | undefined) => function withNetworkContext(wrappedComponent: ReactNode) { @@ -24,30 +25,57 @@ export const networkContextWrapper = (currentNetwork: string | undefined, nodeIn ) : null; }; -export const getPages = (currentNetwork: INetwork | undefined, networks: INetwork[]) => { - const pages = []; - if (networks.length > 0 && currentNetwork !== undefined) { - pages.push({ label: "Explorer", url: `/${currentNetwork.network}/` }); - pages.push({ label: "Visualizer", url: `/${currentNetwork.network}/visualizer/` }); +export const getPages = (currentNetwork: INetwork | undefined, networks: INetwork[]): NavigationRoute[] => { + const hasNetworks = networks.length > 0 && currentNetwork !== undefined; - if (currentNetwork.hasStatisticsSupport) { - pages.push({ label: "Statistics", url: `/${currentNetwork.network}/statistics/` }); - } - } - - return pages; -}; + const { network, hasStatisticsSupport } = currentNetwork ?? { network: "", hasStatisticsSupport: false }; -export const buildUtilities = (currentNetwork: string, networks: INetwork[], identityResolverEnabled: boolean) => { - const utilities = []; - if (networks.length > 0) { - utilities.push({ label: "Streams v0", url: `/${currentNetwork}/streams/0/` }); - if (identityResolverEnabled) { - utilities.push({ label: "Decentralized Identifier", url: `/${currentNetwork}/identity-resolver/` }); - } - } + const routes: NavigationRoute[] = [ + { + label: "Explorer", + url: `/${network}/`, + disabled: !hasNetworks, + }, + { + label: "Visualizer", + url: `/${network}/visualizer/`, + disabled: !hasNetworks, + }, + { + label: "Statistics", + url: `/${network}/statistics/`, + disabled: !hasStatisticsSupport || !hasNetworks, + }, + { + label: "Utilities", + disabled: network !== CHRYSALIS_MAINNET || !hasNetworks, + routes: [ + { label: "Streams v0", url: `/${network}/streams/0/` }, + { + label: "Decentralized Identifier", + url: `/${network}/identity-resolver/`, + disabled: network !== CHRYSALIS_MAINNET, + }, + ], + }, + { + label: "EVM", + routes: [ + { + label: "ShimmerEVM Explorer", + url: "https://explorer.evm.shimmer.network/", + isExternal: true, + }, + { + label: "ShimmerEVM Testnet Testnet", + url: "https://explorer.evm.testnet.shimmer.network/", + isExternal: true, + }, + ], + }, + ]; - return utilities; + return routes; }; /** diff --git a/client/src/app/components/header/Header.scss b/client/src/app/components/header/Header.scss index 339ff9022..d3deb62fb 100644 --- a/client/src/app/components/header/Header.scss +++ b/client/src/app/components/header/Header.scss @@ -150,21 +150,25 @@ header { } .navigation--item, - .utilities--wrapper { + .header-dropdown--wrapper { @include font-size(14px, 21px); display: flex; align-items: center; height: 100%; - margin-left: 40px; color: var(--navbar-color); font-family: $metropolis; font-weight: 600; letter-spacing: 0.01em; + margin-left: 32px; + + &:first-child { + margin-left: 40px; + } } - .utilities--wrapper { - .utilities--dropdown { + .header-dropdown--wrapper { + .header-dropdown--dropdown { display: flex; align-items: center; font-family: $metropolis; @@ -176,12 +180,13 @@ header { color: var(--navbar-color); font-weight: 600; letter-spacing: 0.01em; + text-wrap: nowrap; } .icon { display: flex; align-items: center; - margin-left: 8px; + margin-left: 4px; span { margin-bottom: 3px; @@ -204,14 +209,14 @@ header { } } - .utilities { + .header-dropdown { padding: 64px 120px 120px 120px; & * { margin-bottom: 8px; } - .utilities--label { + .header-dropdown--label { color: var(--navbar-color); font-family: $metropolis; font-weight: 700; @@ -221,7 +226,7 @@ header { @include font-size(14px, 21px); } - .utilities--item a { + .header-dropdown--item a { color: $gray-7; font-family: $inter; letter-spacing: 0.5px; @@ -231,11 +236,14 @@ header { } } - .utilities--mobile { + .header-dropdown--mobile { transition: opacity 0.3s ease-in-out; opacity: 0; + height: 0; + overflow: hidden; &.opened { + height: auto; opacity: 1; } } @@ -296,7 +304,7 @@ header { @include desktop-down { .navigation--item, - .utilities--wrapper, + .header-dropdown--wrapper, .search-input { display: none; } diff --git a/client/src/app/components/header/Header.tsx b/client/src/app/components/header/Header.tsx index 88e647d0f..61f50a265 100644 --- a/client/src/app/components/header/Header.tsx +++ b/client/src/app/components/header/Header.tsx @@ -1,324 +1,225 @@ -/* eslint-disable react/jsx-closing-tag-location */ +import React, { useEffect, useState } from "react"; +import * as H from "history"; import classNames from "classnames"; -import React, { Component, ReactNode } from "react"; import { Link } from "react-router-dom"; -import { HeaderProps } from "./HeaderProps"; -import { HeaderState } from "./HeaderState"; import Logo from "~assets/logo-header.svg?react"; +import { IDropdownRoute, IRoute } from "~/app/lib/interfaces"; import mainChrysalisMessage from "~assets/modals/chrysalis/search/main-header.json"; import mainLegacyMessage from "~assets/modals/legacy/search/main-header.json"; import mainStardustMessage from "~assets/modals/stardust/search/main-header.json"; import ShimmerLogo from "~assets/shimmer-logo-header.svg?react"; import { ServiceFactory } from "~factories/serviceFactory"; import { isMarketedNetwork, isShimmerUiTheme } from "~helpers/networkHelper"; -import { CHRYSALIS, LEGACY, STARDUST } from "~models/config/protocolVersion"; +import { CHRYSALIS, LEGACY, ProtocolVersion, STARDUST } from "~models/config/protocolVersion"; import { SettingsService } from "~services/settingsService"; import FiatSelector from "../FiatSelector"; -import "./Header.scss"; import Modal from "../Modal"; import NetworkSwitcher from "../NetworkSwitcher"; +import { INetwork } from "~/models/config/INetwork"; +import SearchInput from "../SearchInput"; +import HeaderDropdown from "./HeaderDropdown"; +import "./Header.scss"; -/** - * Component which will show the header. - */ -class Header extends Component { - /** - * Settings service. - */ - private readonly _settingsService: SettingsService; +const NETWORK_DROPDOWN_LABEL = "Network Switcher"; - /** - * Create a new instance of Header. - * @param props The props. - */ - constructor(props: HeaderProps) { - super(props); +const MODAL_MESSAGE: Record = { + [LEGACY]: mainLegacyMessage, + [CHRYSALIS]: mainChrysalisMessage, + [STARDUST]: mainStardustMessage, +}; - this._settingsService = ServiceFactory.get("settings"); +interface IHeader { + rootPath: string; + currentNetwork?: INetwork; + networks: INetwork[]; + history?: H.History; + action?: string; + protocolVersion: ProtocolVersion; + pages?: (IRoute | IDropdownRoute)[]; +} - this.state = { - isNetworkSwitcherExpanded: false, - isUtilitiesExpanded: false, - isMenuExpanded: false, - darkMode: this._settingsService.get().darkMode ?? false, - show: false, - }; - } +export default function Header({ rootPath, currentNetwork, networks, history, action, protocolVersion, pages: routes }: IHeader) { + const settingsService = ServiceFactory.get("settings"); + + const [isMenuExpanded, setIsMenuExpanded] = useState(false); + const [darkMode, setDarkMode] = useState(settingsService.get().darkMode ?? false); + const [show, setShow] = useState(false); + const [expandedDropdownLabel, setExpandedDropdownLabel] = useState(); + + const isNetworkSwitcherExpanded = expandedDropdownLabel === NETWORK_DROPDOWN_LABEL; + const isShimmerUi = isShimmerUiTheme(currentNetwork?.uiTheme); + const isMarketed = isMarketedNetwork(currentNetwork?.network); + + useEffect(() => { + toggleModeClass(); + }, []); /** - * The component mounted. + * Toggle the display mode. */ - public componentDidMount(): void { - if (this.state.darkMode) { - this.toggleModeClass(); + function toggleMode(): void { + setDarkMode((darkMode) => !darkMode); + settingsService.saveSingle("darkMode", darkMode); + const event = new CustomEvent("theme-change", { detail: { darkMode: darkMode } }); + window.dispatchEvent(event); + toggleModeClass(); + } + + function toggleModeClass(): void { + const body = document.querySelector("body"); + if (body) { + body.classList.toggle("darkmode", darkMode); } } - /** - * Render the component. - * @returns The node to render. - */ - public render(): ReactNode { - const { rootPath, currentNetwork, networks, history, action, search, utilities, pages } = this.props; - const isShimmerUi = isShimmerUiTheme(currentNetwork?.uiTheme); - const isMarketed = isMarketedNetwork(currentNetwork?.network); + function resetExpandedDropdowns(e?: React.MouseEvent): void { + setIsMenuExpanded(false); + closeDropdowns(); + } - return ( -
- +
+ ); } - -export default Header; diff --git a/client/src/app/components/header/HeaderDropdown.tsx b/client/src/app/components/header/HeaderDropdown.tsx new file mode 100644 index 000000000..7a2e6f116 --- /dev/null +++ b/client/src/app/components/header/HeaderDropdown.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { IDropdownRoute } from "~/app/lib/interfaces"; +import classNames from "classnames"; +import NavigationRouteHelper from "./NavigationRouteHelper"; + +interface INavigationDropdown extends IDropdownRoute { + isExpanded: boolean; + setExpandedDropdownId: (label?: string) => void; + setIsMenuExpanded?: (isExpanded: boolean) => void; +} + +interface IDropdownProps extends INavigationDropdown { + toggleDropdown: () => void; +} + +/** + * Dropdown component for header. + */ +export default function HeaderDropdown(props: INavigationDropdown & { mobileOnly?: boolean }): React.JSX.Element { + const { isExpanded, setExpandedDropdownId, mobileOnly, label } = props; + const DropdownComponent = mobileOnly ? MobileDropdown : DesktopDropdown; + + const toggleDropdown = (): void => setExpandedDropdownId(isExpanded ? undefined : label); + + return ; +} + +/** + * Dropdown component for desktop. + */ +const DesktopDropdown = ({ + label, + disabled, + routes, + isExpanded, + toggleDropdown, + setExpandedDropdownId, +}: IDropdownProps): React.JSX.Element => { + const closeDropdown = (e?: React.MouseEvent): void => setExpandedDropdownId(); + + return ( + <> + {!disabled && ( +
+
+
{label}
+
+ expand_more +
+
+ +
+
+
{label}
+ {routes + .filter(({ disabled }) => !disabled) + .map((route) => ( +
+ + {route.label} + +
+ ))} +
+
+ {isExpanded &&
} +
+ )} + + ); +}; + +/** + * Dropdown component for mobile. + */ +const MobileDropdown = ({ + label, + disabled, + routes, + isExpanded, + toggleDropdown, + setExpandedDropdownId, + setIsMenuExpanded, +}: IDropdownProps): React.JSX.Element => { + function handleRouteClick(e?: React.MouseEvent): void { + setExpandedDropdownId(); + setIsMenuExpanded?.(false); + } + + return ( + <> + {disabled && ( + <> +
  • +
    {label}
    +
    + expand_more +
    +
  • +
    + {routes + .filter(({ disabled }) => !disabled) + .map((route) => ( + +
  • + {route.label} +
  • +
    + ))} +
    + + )} + + ); +}; diff --git a/client/src/app/components/header/HeaderProps.ts b/client/src/app/components/header/HeaderProps.ts deleted file mode 100644 index 035e55794..000000000 --- a/client/src/app/components/header/HeaderProps.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as H from "history"; -import { ReactNode } from "react"; -import { INetwork } from "~models/config/INetwork"; - -/** - * The props for the Header component. - */ -export interface HeaderProps { - /** - * The root path. - */ - rootPath: string; - - /** - * The currently selected network. - */ - currentNetwork?: INetwork; - - /** - * The networks available. - */ - networks: INetwork[]; - - /** - * History for navigation. - */ - history?: H.History; - - /** - * Action for navigation. - */ - action?: string; - - /** - * The search elements to display as content. - */ - search?: ReactNode; - - /** - * Utilities menu - */ - utilities?: { - /** - * The label for the utility. - */ - label: string; - - /** - * The link for the utility. - */ - url: string; - }[]; - - /** - * Pages menu - */ - pages?: { - /** - * The label for the page. - */ - label: string; - - /** - * The link for the page. - */ - url: string; - }[]; -} diff --git a/client/src/app/components/header/HeaderState.ts b/client/src/app/components/header/HeaderState.ts deleted file mode 100644 index a03913efb..000000000 --- a/client/src/app/components/header/HeaderState.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The state for the Header component. - */ -export interface HeaderState { - /** - * Is the network switcher menu expanded. - */ - isNetworkSwitcherExpanded: boolean; - - /** - * Is the utilities menu expanded. - */ - isUtilitiesExpanded: boolean; - - /** - * Is the hamburger menu expanded. - */ - isMenuExpanded: boolean; - - /** - * Darkmode theme - */ - darkMode: boolean; - - /** - * Show info modal on full page. - */ - show: boolean; -} diff --git a/client/src/app/components/header/NavigationRouteHelper.tsx b/client/src/app/components/header/NavigationRouteHelper.tsx new file mode 100644 index 000000000..39ccc2128 --- /dev/null +++ b/client/src/app/components/header/NavigationRouteHelper.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { IRoute } from "~/app/lib/interfaces"; +import { Link } from "react-router-dom"; + +type InternalRouteProps = React.ComponentPropsWithoutRef; +type ExternalRouteProps = React.DetailedHTMLProps, HTMLAnchorElement>; + +type Route = (InternalRouteProps | ExternalRouteProps) & { route: IRoute }; + +export default function NavigationRouteHelper({ children, route, ...linkProps }: React.PropsWithChildren) { + if (route.isExternal) { + const externalProps: ExternalRouteProps = { ...linkProps, href: route.url, target: "_blank", rel: "noopener noreferrer" }; + return {children}; + } else { + const internalProps: InternalRouteProps = { ...linkProps, to: route.url }; + return {children}; + } +} diff --git a/client/src/app/lib/interfaces/index.ts b/client/src/app/lib/interfaces/index.ts new file mode 100644 index 000000000..204986b69 --- /dev/null +++ b/client/src/app/lib/interfaces/index.ts @@ -0,0 +1 @@ +export * from "./routes.interfaces"; diff --git a/client/src/app/lib/interfaces/routes.interfaces.ts b/client/src/app/lib/interfaces/routes.interfaces.ts new file mode 100644 index 000000000..63655211e --- /dev/null +++ b/client/src/app/lib/interfaces/routes.interfaces.ts @@ -0,0 +1,15 @@ +export interface IBaseNavigationRoute { + label: string; + disabled?: boolean; +} + +export interface IRoute extends IBaseNavigationRoute { + url: string; + isExternal?: boolean; +} + +export interface IDropdownRoute extends IBaseNavigationRoute { + routes: IRoute[]; +} + +export type NavigationRoute = IRoute | IDropdownRoute; From fd292c2deaf2c797e33a4371c0406a4fa215ae42 Mon Sep 17 00:00:00 2001 From: Eugene P Date: Wed, 31 Jan 2024 18:46:40 +0200 Subject: [PATCH 3/4] fix: incorrect balance on alias address (#1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: incorrect balance on alias address #910 Signed-off-by: Eugene Panteleymonchuk * chore: move check alias output balance to useAddressBalance. Signed-off-by: Eugene Panteleymonchuk * feat: improve address outputs balances * revert: client/src/helpers/hooks/useAliasDetails.ts * fix: add missing reactive condition * fix: string numbers * enhancement: load time --------- Signed-off-by: Eugene Panteleymonchuk Co-authored-by: Begoña Alvarez --- .../stardust/address/AddressBalance.tsx | 13 ++--- .../src/app/routes/stardust/AddressPage.tsx | 4 +- .../src/app/routes/stardust/AddressState.ts | 50 +++++++++++-------- client/src/helpers/hooks/useAddressBalance.ts | 34 ++++++++++--- .../helpers/stardust/transactionsHelper.ts | 2 +- 5 files changed, 61 insertions(+), 42 deletions(-) diff --git a/client/src/app/components/stardust/address/AddressBalance.tsx b/client/src/app/components/stardust/address/AddressBalance.tsx index c548a674f..432690d4f 100644 --- a/client/src/app/components/stardust/address/AddressBalance.tsx +++ b/client/src/app/components/stardust/address/AddressBalance.tsx @@ -21,13 +21,13 @@ interface AddressBalanceProps { /** * The storage rent balance. */ - readonly storageRentBalance: number | null; + readonly storageDeposit: number | null; } const CONDITIONAL_BALANCE_INFO = "These funds reside within outputs with additional unlock conditions which might be potentially un-lockable"; -const AddressBalance: React.FC = ({ balance, spendableBalance, storageRentBalance }) => { +const AddressBalance: React.FC = ({ balance, spendableBalance, storageDeposit }) => { const { name: network, tokenInfo } = useContext(NetworkContext); const [formatBalanceFull, setFormatBalanceFull] = useState(false); const [formatConditionalBalanceFull, setFormatConditionalBalanceFull] = useState(false); @@ -101,14 +101,7 @@ const AddressBalance: React.FC = ({ balance, spendableBalan false, conditionalBalance, )} - {buildBalanceView( - "Storage Deposit", - formatStorageBalanceFull, - setFormatStorageBalanceFull, - false, - false, - storageRentBalance, - )} + {buildBalanceView("Storage Deposit", formatStorageBalanceFull, setFormatStorageBalanceFull, false, false, storageDeposit)}
    ); diff --git a/client/src/app/routes/stardust/AddressPage.tsx b/client/src/app/routes/stardust/AddressPage.tsx index 8972379d8..f5a8deeb5 100644 --- a/client/src/app/routes/stardust/AddressPage.tsx +++ b/client/src/app/routes/stardust/AddressPage.tsx @@ -24,7 +24,7 @@ const AddressPage: React.FC> = ({ bech32AddressDetails, balance, availableBalance, - storageRentBalance, + storageDeposit, isBasicOutputsLoading, isAliasOutputsLoading, isNftOutputsLoading, @@ -88,7 +88,7 @@ const AddressPage: React.FC> = ({ )}
    diff --git a/client/src/app/routes/stardust/AddressState.ts b/client/src/app/routes/stardust/AddressState.ts index 19d7ec90b..17fb0c1ab 100644 --- a/client/src/app/routes/stardust/AddressState.ts +++ b/client/src/app/routes/stardust/AddressState.ts @@ -1,17 +1,20 @@ import { Bech32Helper } from "@iota/iota.js"; import { - HexEncodedString, + AddressType, AliasOutput, + BasicOutput, + FeatureType, + HexEncodedString, MetadataFeature, + Output, OutputResponse, OutputType, - AddressType, - Output, - BasicOutput, - FeatureType, } from "@iota/sdk-wasm/web"; import { Reducer, useContext, useEffect, useReducer } from "react"; import { useLocation, useParams } from "react-router-dom"; +import { useAliasContainsDID } from "~/helpers/hooks/useAliasContainsDID"; +import { useResolvedDID } from "~/helpers/hooks/useResolvedDID"; +import { IDIDResolverResponse } from "~/models/api/IDIDResolverResponse"; import { useAddressAliasOutputs } from "~helpers/hooks/useAddressAliasOutputs"; import { useAddressBalance } from "~helpers/hooks/useAddressBalance"; import { useAddressBasicOutputs } from "~helpers/hooks/useAddressBasicOutputs"; @@ -30,15 +33,12 @@ import { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; import { IParticipation } from "~models/api/stardust/participation/IParticipation"; import NetworkContext from "../../context/NetworkContext"; import { AddressRouteProps } from "../AddressRouteProps"; -import { useAliasContainsDID } from "~/helpers/hooks/useAliasContainsDID"; -import { useResolvedDID } from "~/helpers/hooks/useResolvedDID"; -import { IDIDResolverResponse } from "~/models/api/IDIDResolverResponse"; export interface IAddressState { bech32AddressDetails: IBech32AddressDetails | null; balance: number | null; availableBalance: number | null; - storageRentBalance: number | null; + storageDeposit: number | null; addressOutputs: OutputResponse[] | null; addressBasicOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; @@ -70,7 +70,7 @@ const initialState = { bech32AddressDetails: null, balance: null, availableBalance: null, - storageRentBalance: null, + storageDeposit: null, addressOutputs: null, addressBasicOutputs: null, isBasicOutputsLoading: true, @@ -121,13 +121,16 @@ export const useAddressPageState = (): [IAddressState, React.Dispatch { - if (addressBasicOutputs && addressAliasOutputs && addressNftOutputs) { - const mergedOutputResponses = [...addressBasicOutputs, ...addressAliasOutputs, ...addressNftOutputs]; - const outputs = mergedOutputResponses.map((or) => or.output); - const storageRentBalanceUpdate = TransactionsHelper.computeStorageRentBalance(outputs, rentStructure); - - setState({ - addressOutputs: mergedOutputResponses, - storageRentBalance: storageRentBalanceUpdate, - }); + const addressOutputs = + [...(addressBasicOutputs ?? []), ...(addressAliasOutputs ?? []), ...(addressNftOutputs ?? [])].filter((o) => o !== null) ?? []; + let outputsComputedInStorageDeposit = addressOutputs?.map((or) => or.output); + const addressOutputItself = nftOutput ?? aliasOutput; + if (addressOutputItself) { + outputsComputedInStorageDeposit = [...outputsComputedInStorageDeposit, addressOutputItself]; } + const storageDeposit = TransactionsHelper.computeStorageDeposit(outputsComputedInStorageDeposit, rentStructure); + + setState({ + addressOutputs, + storageDeposit, + }); if (addressBasicOutputs && !state.participations) { let foundParticipations: IParticipation[] = []; for (const outputResponse of addressBasicOutputs) { @@ -231,7 +237,7 @@ export const useAddressPageState = (): [IAddressState, React.Dispatch(`api-client-${STARDUST}`)); const [balance, setBalance] = useState(null); @@ -19,20 +26,33 @@ export function useAddressBalance(network: string, address: string | null): [num useEffect(() => { setIsLoading(true); - if (address) { + const address = addressDetails?.bech32; + const needsOutputToProceed = addressDetails?.type === AddressType.Alias || addressDetails?.type === AddressType.Nft; + const canLoad = address && (!needsOutputToProceed || (needsOutputToProceed && output)); + if (canLoad) { // eslint-disable-next-line no-void void (async () => { const response = await apiClient.addressBalanceChronicle({ network, address }); if (response?.totalBalance !== undefined && isMounted) { - setBalance(response.totalBalance); - setAvailableBalance(response.availableBalance ?? null); + let totalBalance = response.totalBalance; + let availableBalance = response.availableBalance ?? 0; + if (output) { + totalBalance = Number(totalBalance) + Number(output.amount); + availableBalance = Number(availableBalance) + Number(output.amount); + } + setBalance(totalBalance); + setAvailableBalance(availableBalance > 0 ? availableBalance : null); } else if (isMounted) { // Fallback balance from iotajs (node) const addressDetailsWithBalance = await apiClient.addressBalance({ network, address }); if (addressDetailsWithBalance && isMounted) { - setBalance(Number(addressDetailsWithBalance.balance)); + let totalBalance = Number(addressDetailsWithBalance.balance); + if (output) { + totalBalance = Number(totalBalance) + Number(output.amount); + } + setBalance(totalBalance); setAvailableBalance(null); } } @@ -40,7 +60,7 @@ export function useAddressBalance(network: string, address: string | null): [num } else { setIsLoading(false); } - }, [network, address]); + }, [network, addressDetails, output]); return [balance, availableBalance, isLoading]; } diff --git a/client/src/helpers/stardust/transactionsHelper.ts b/client/src/helpers/stardust/transactionsHelper.ts index 3fd705069..1a04615db 100644 --- a/client/src/helpers/stardust/transactionsHelper.ts +++ b/client/src/helpers/stardust/transactionsHelper.ts @@ -237,7 +237,7 @@ export class TransactionsHelper { return HexHelper.toBigInt256(nftId).eq(bigInt.zero) ? Utils.computeNftId(outputId) : nftId; } - public static computeStorageRentBalance(outputs: Output[], rentStructure: IRent): number { + public static computeStorageDeposit(outputs: Output[], rentStructure: IRent): number { const outputsWithoutSdruc = outputs.filter((output) => { if (output.type === OutputType.Treasury) { return false; From 5afd0bd82d1c464ceb242aa77ca3aca4ef9aee8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bego=C3=B1a=20Alvarez?= Date: Wed, 31 Jan 2024 17:49:41 +0100 Subject: [PATCH 4/4] chore: bump version to v3.3.3-rc.1 --- api/package-lock.json | 4 ++-- api/package.json | 2 +- client/package-lock.json | 4 ++-- client/package.json | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 5d56a58b0..b97a5828c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "explorer-api", - "version": "3.3.2", + "version": "3.3.3-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "explorer-api", - "version": "3.3.2", + "version": "3.3.3-rc.1", "license": "Apache-2.0", "dependencies": { "@google-cloud/logging-winston": "^5.3.0", diff --git a/api/package.json b/api/package.json index c893c6786..34d999727 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "explorer-api", "description": "API for Tangle Explorer", - "version": "3.3.2", + "version": "3.3.3-rc.1", "author": "Martyn Janes ", "repository": { "type": "git", diff --git a/client/package-lock.json b/client/package-lock.json index 63f013734..88381d104 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "explorer-client", - "version": "3.3.2", + "version": "3.3.3-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "explorer-client", - "version": "3.3.2", + "version": "3.3.3-rc.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/client/package.json b/client/package.json index dcd3fd566..01eb96f82 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "explorer-client", "description": "Tangle Explorer UI", - "version": "3.3.2", + "version": "3.3.3-rc.1", "author": "Martyn Janes ", "type": "module", "repository": { diff --git a/package.json b/package.json index 737fdc362..765d175a1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "explorer", "description": "Tangle Explorer", - "version": "3.3.2", + "version": "3.3.3-rc.1", "scripts": { "setup:client": "cd client && npm install && npm run postinstall", "setup:api": "cd api && npm install && npm run build-compile && npm run build-config",