diff --git a/src/components/token/TokenBalances.js b/src/components/token/TokenBalances.js index 6e766eb..0a7e80d 100644 --- a/src/components/token/TokenBalances.js +++ b/src/components/token/TokenBalances.js @@ -5,372 +5,367 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; +import { get, last, find, isEmpty } from 'lodash'; +import { useHistory } from 'react-router-dom'; +import { numberUtils, constants as hathorLibConstants } from '@hathor/wallet-lib'; import TokenBalancesTable from './TokenBalancesTable'; import tokensApi from '../../api/tokensApi'; -import { get, last, find, isEmpty } from 'lodash'; import PaginationURL from '../../utils/pagination'; -import { withRouter } from 'react-router-dom'; import ErrorMessageWithIcon from '../error/ErrorMessageWithIcon'; import TokenAutoCompleteField from './TokenAutoCompleteField'; -import helpers from '../../utils/helpers'; -import { numberUtils, constants as hathorLibConstants } from '@hathor/wallet-lib'; /** * Displays custom tokens in a table with pagination buttons and a search bar. */ -class TokenBalances extends React.Component { +function TokenBalances({ maintenanceMode }) { + const history = useHistory(); + /** - * Structure that contains the attributes that will be part of the page URL + * tokenBalances: List of token balances currently being rendered. + * Each token balance element must have the fields: address, locked_balance, unlocked_balance, total, token_id and sort. + * id, name, symbol are strings; nft is boolean; transaction_timestamp is long. + * Sort is an array with two strings, The value is given by ElasticSearch and it is passed back when we want to change page + * hasAfter: Indicates if a next page exists + * hasBefore: Indicates if a previous page exists + * sortBy: Which field to sort (uid, name, symbol) + * order: If sorted field must be ordered asc or desc + * page: Current page. Used to know if there is a previous page + * pageSearchAfter: Calculates the searchAfter param that needs to be passed to explorer-service in order to get the next/previous page. + * We use this array to store already-calculated values, + * so we do not need to recalculate them if user is requesting an already-navigated page. + * loading: Initial loading, when user clicks on the Tokens navigation item + * isSearchLoading: Indicates if search results are being retrieved from explorer-service + * calculatingPage: Indicates if next page is being retrieved from explorer-service + * error: Indicates if an unexpected error happened when calling the explorer-service + * tokenBalanceInformationError: Indicates if an unexpected error happened when calling the token balance information service + * maintenanceMode: Indicates if explorer-service or its downstream services are experiencing problems. If so, maintenance mode will be enabled on + * our feature toggle service (Unleash) to remove additional load until the team fixes the problem + * transactionsCount: Number of transactions for the searched token + * addressesCount: Number of addressed for the searched token + * tokensApiError: Flag indicating if the request to the token api failed, to decide wether to display or not the total number of transactions */ - pagination = new PaginationURL({ - sortBy: { required: false }, - order: { required: false }, - token: { required: false }, - }); - - constructor(props) { - super(props); + const [tokenId, setTokenId] = useState(hathorLibConstants.NATIVE_TOKEN_UID); + const [tokenBalances, setTokenBalances] = useState([]); + const [hasAfter, setHasAfter] = useState(false); + const [hasBefore, setHasBefore] = useState(false); + const [sortBy, setSortBy] = useState(null); + const [order, setOrder] = useState(null); + const [page, setPage] = useState(1); + const [pageSearchAfter, setPageSearchAfter] = useState([]); + const [loading, setLoading] = useState(true); + const [isSearchLoading, setIsSearchLoading] = useState(false); + const [calculatingPage, setCalculatingPage] = useState(false); + const [error, setError] = useState(false); + const [tokenBalanceInformationError, setTokenBalanceInformationError] = useState(false); + const [transactionsCount, setTransactionsCount] = useState(0); + const [addressesCount, setAddressesCount] = useState(0); + const [tokensApiError, setTokensApiError] = useState(false); + /** + * Structure that contains the attributes that will be part of the page URL + */ + const pagination = useRef( + new PaginationURL({ + sortBy: { required: false }, + order: { required: false }, + token: { required: false }, + }) + ); + + const fetchHTRTransactionCount = useCallback(async () => { + const tokenApiResponse = await tokensApi.getToken(hathorLibConstants.NATIVE_TOKEN_UID); + + setTokensApiError(get(tokenApiResponse, 'error', false)); + setTransactionsCount(get(tokenApiResponse, 'data.hits[0].transactions_count', 0)); + }, []); + + // Initialization effect, incorporating querystring parameters + useEffect(() => { // Get default states from query params and default values - const queryParams = this.pagination.obtainQueryParams(); - - const sortBy = get(queryParams, 'sortBy', 'total'); - const order = get(queryParams, 'order', 'desc'); - const tokenId = get(queryParams, 'token', hathorLibConstants.NATIVE_TOKEN_UID); - - /** - * tokenBalances: List of token balances currently being rendered. - * Each token balance element must have the fields: address, locked_balance, unlocked_balance, total, token_id and sort. - * id, name, symbol are strings; nft is boolean; transaction_timestamp is long. - * Sort is an array with two strings, The value is given by ElasticSearch and it is passed back when we want to change page - * hasAfter: Indicates if a next page exists - * hasBefore: Indicates if a previous page exists - * sortBy: Which field to sort (uid, name, symbol) - * order: If sorted field must be ordered asc or desc - * page: Current page. Used to know if there is a previous page - * pageSearchAfter: Calculates the searchAfter param that needs to be passed to explorer-service in order to get the next/previous page. - * We use this array to store already-calculated values, - * so we do not need to recalculate them if user is requesting an already-navigated page. - * loading: Initial loading, when user clicks on the Tokens navigation item - * isSearchLoading: Indicates if search results are being retrieved from explorer-service - * calculatingPage: Indicates if next page is being retrieved from explorer-service - * error: Indicates if an unexpected error happened when calling the explorer-service - * tokenBalanceInformationError: Indicates if an unexpected error happened when calling the token balance information service - * maintenanceMode: Indicates if explorer-service or its downstream services are experiencing problems. If so, maintenance mode will be enabled on - * our feature toggle service (Unleash) to remove additional load until the team fixes the problem - * transactionsCount: Number of transactions for the searched token - * addressesCount: Number of addressed for the searched token - * tokensApiError: Flag indicating if the request to the token api failed, to decide wether to display or not the total number of transactions - */ - this.state = { - tokenId, - tokenBalances: [], - hasAfter: false, - hasBefore: false, - sortBy, - order, - page: 1, - pageSearchAfter: [], - loading: true, - isSearchLoading: false, - calculatingPage: false, - error: false, - tokenBalanceInformationError: false, - maintenanceMode: this.props.maintenanceMode, - transactionsCount: 0, - addressesCount: 0, - tokensApiError: false, - }; - } + const queryParams = pagination.current.obtainQueryParams(); - componentDidMount = async () => { - if (this.state.maintenanceMode) { - return; - } + const querySortBy = get(queryParams, 'sortBy', 'total'); + const queryOrder = get(queryParams, 'order', 'desc'); + const queryTokenId = get(queryParams, 'token', hathorLibConstants.NATIVE_TOKEN_UID); - if (this.state.tokenId === hathorLibConstants.NATIVE_TOKEN_UID) { + setSortBy(querySortBy); + setOrder(queryOrder); + setTokenId(queryTokenId); + + if (queryTokenId === hathorLibConstants.NATIVE_TOKEN_UID) { // If we had a custom token as queryParam // then we will perform the search after the token // is found in the elastic search // otherwise we just perform the search for HTR to show the default screen - await this.performSearch(); + setIsSearchLoading(true); // Since we did not search for the HTR token (it is the default), we need to fetch // it to retrieve the transactions count - await this.fetchHTRTransactionCount(); - - this.setState({ - loading: false, - }); + fetchHTRTransactionCount().catch(e => + console.error('Error on initial fetchHTRTransactionCount', e) + ); } - }; + }, [fetchHTRTransactionCount]); /** * Call explorer-service to get list of token balances for a given tokenId * + * @param queryTokenId + * @param querySortBy + * @param queryOrder * @param {*} searchAfter Parameter needed by ElasticSearch for pagination purposes * @returns tokens */ - getTokenBalances = async searchAfter => { - const tokenBalancesRequest = await tokensApi.getBalances( - this.state.tokenId, - this.state.sortBy, - this.state.order, - searchAfter - ); - this.setState({ - error: get(tokenBalancesRequest, 'error', false), - }); - return get(tokenBalancesRequest, 'data', { hits: [], has_next: false }); - }; + const getTokenBalances = useCallback( + async (queryTokenId, querySortBy, queryOrder, searchAfter) => { + const tokenBalancesResponse = await tokensApi.getBalances( + queryTokenId, + querySortBy, + queryOrder, + searchAfter + ); + setError(tokenBalancesResponse.error || false); + return tokenBalancesResponse.data || { hits: [], has_next: false }; + }, + [] + ); + + const loadTokenBalanceInformation = useCallback(async queryTokenId => { + const tokenBalanceInformationResponse = await tokensApi.getBalanceInformation(queryTokenId); + setTokenBalanceInformationError(tokenBalanceInformationResponse.error || false); - loadTokenBalanceInformation = async () => { - const tokenBalanceInformationRequest = await tokensApi.getBalanceInformation( - this.state.tokenId + return ( + tokenBalanceInformationResponse.data || { + transactions: 0, + addresses: 0, + } ); - this.setState({ - tokenBalanceInformationError: get(tokenBalanceInformationRequest, 'error', false), - }); - - return get(tokenBalanceInformationRequest, 'data', { - transactions: 0, - addresses: 0, - }); - }; + }, []); + + /** + * Update the URL, so user can share the results of a search + */ + const updateURL = useCallback( + (newTokenId, newSortBy, newOrder) => { + const newURL = pagination.current.setURLParameters({ + token: newTokenId, + sortBy: newSortBy, + order: newOrder, + }); + + history.push(newURL); + }, + [history] + ); /** + * Effect that reacts to the `isSearchLoading` flag * Calls ElasticSearch (through the Explorer Service) with state data, sets loading and URL information */ - performSearch = async () => { - this.setState({ isSearchLoading: true }); - const tokenBalances = await this.getTokenBalances([]); - const tokenBalanceInformation = await this.loadTokenBalanceInformation(); - - this.setState({ - isSearchLoading: false, - loading: false, - page: 1, - tokenBalances: tokenBalances.hits, - addressesCount: tokenBalanceInformation.addresses, - hasAfter: tokenBalances.has_next, - hasBefore: false, - pageSearchAfter: [ + useEffect(() => { + const performSearch = async () => { + const fetchedTokenBalances = await getTokenBalances(tokenId, sortBy, order, []); + const tokenBalanceInformation = await loadTokenBalanceInformation(tokenId); + + setIsSearchLoading(false); + setLoading(false); + setPage(1); + setTokenBalances(fetchedTokenBalances.hits); + setAddressesCount(tokenBalanceInformation.addresses); + setHasAfter(fetchedTokenBalances.has_next); + setHasBefore(false); + setPageSearchAfter([ { page: 1, searchAfter: [], }, - ], - }); + ]); - // This is ultimately called when search text, sort, or sort order changes - this.updateURL(); - }; + // This is ultimately called when search text, sort, or sort order changes + updateURL(tokenId, sortBy, order); + }; - /** - * Update the URL, so user can share the results of a search - */ - updateURL = () => { - const newURL = this.pagination.setURLParameters({ - sortBy: this.state.sortBy, - order: this.state.order, - token: this.state.tokenId, - }); - - this.props.history.push(newURL); - }; + if (!isSearchLoading) { + // Ignore this effect it the isSearchLoading flag is inactive + return; + } + performSearch().catch(e => console.error('Error on performSearch effect', e)); + }, [ + isSearchLoading, + tokenId, + sortBy, + order, + getTokenBalances, + loadTokenBalanceInformation, + updateURL, + ]); + + // TODO: Maybe those clicked functions must setCalculatingPage and let the rest be handled in an effect /** * Process events when next page is requested by user */ - nextPageClicked = async () => { - this.setState({ calculatingPage: true }); + const nextPageClicked = async () => { + setCalculatingPage(true); - const nextPage = this.state.page + 1; - let searchAfter = get(find(this.state.pageSearchAfter, { page: nextPage }), 'searchAfter', []); + const nextPage = page + 1; + let searchAfter = get(find(pageSearchAfter, { page: nextPage }), 'searchAfter', []); // Calculate searchAfter of next page if not already calculated if (isEmpty(searchAfter)) { - const lastCurrentTokenSort = get(last(this.state.tokenBalances), 'sort', []); + const lastCurrentTokenSort = get(last(tokenBalances), 'sort', []); const newEntry = { page: nextPage, searchAfter: lastCurrentTokenSort, }; - this.setState({ - pageSearchAfter: [...this.state.pageSearchAfter, newEntry], - }); + setPageSearchAfter([...pageSearchAfter, newEntry]); searchAfter = lastCurrentTokenSort; } - const tokenBalances = await this.getTokenBalances(searchAfter); + const fetchedTokenBalances = await getTokenBalances(tokenId, sortBy, order, searchAfter); - this.setState({ - tokenBalances: tokenBalances.hits, - hasAfter: tokenBalances.has_next, - hasBefore: true, - page: nextPage, - calculatingPage: false, - }); + setTokenBalances(fetchedTokenBalances.hits); + setHasAfter(fetchedTokenBalances.has_next); + setHasBefore(true); + setPage(nextPage); + setCalculatingPage(false); }; /** * Process events when previous page is requested by user */ - previousPageClicked = async () => { - this.setState({ calculatingPage: true }); - - const previousPage = this.state.page - 1; - const searchAfter = get( - find(this.state.pageSearchAfter, { page: previousPage }), - 'searchAfter', - [] - ); - const tokenBalances = await this.getTokenBalances(searchAfter); - - this.setState({ - tokenBalances: tokenBalances.hits, - hasAfter: true, - hasBefore: previousPage === 1 ? false : true, - page: previousPage, - calculatingPage: false, - }); - }; - - fetchHTRTransactionCount = async () => { - const tokenApiRequest = await tokensApi.getToken(hathorLibConstants.NATIVE_TOKEN_UID); - - this.setState({ - tokensApiError: get(tokenApiRequest, 'error', false), - transactionsCount: get(tokenApiRequest, 'data.hits[0].transactions_count', 0), - }); + const previousPageClicked = async () => { + setCalculatingPage(true); + + const previousPage = page - 1; + const searchAfter = get(find(pageSearchAfter, { page: previousPage }), 'searchAfter', []); + const fetchedTokenBalances = await getTokenBalances(tokenId, sortBy, order, searchAfter); + + setTokenBalances(fetchedTokenBalances.hits); + setHasAfter(true); + setHasBefore(previousPage !== 1); + setPage(previousPage); + setCalculatingPage(false); }; - onTokenSelected = async token => { - if (!token) { - await helpers.setStateAsync(this, { - tokenId: hathorLibConstants.NATIVE_TOKEN_UID, - }); + const onTokenSelected = async newToken => { + const newTokenId = newToken?.id || hathorLibConstants.NATIVE_TOKEN_UID; + setTokenId(newTokenId); + if (!newToken) { // HTR token is the default, so the search API is not called, we must forcefully call it // so we can retrieve the transactions count information - await this.fetchHTRTransactionCount(); - - this.performSearch(); - return; + await fetchHTRTransactionCount(); // TODO: Confirm this behavior + } else { + setTransactionsCount(newToken.transactions_count); + setTokensApiError(false); } - await helpers.setStateAsync(this, { - tokenId: token.id, - transactionsCount: token.transactions_count, - tokensApiError: false, - }); - - this.performSearch(); + // Trigger the performSearch effect + setIsSearchLoading(true); }; /** * Process table header click. This indicates that user wants data to be sorted by a determined field * - * @param {*} event + * @param {*} _event * @param {*} header */ - tableHeaderClicked = async (_event, header) => { - if (header === this.state.sortBy) { - await helpers.setStateAsync(this, { order: this.state.order === 'asc' ? 'desc' : 'asc' }); + const tableHeaderClicked = async (_event, header) => { + if (header === sortBy) { + setOrder(order === 'asc' ? 'desc' : 'asc'); } else { - await helpers.setStateAsync(this, { sortBy: header, order: 'asc' }); + setSortBy(header); + setOrder('asc'); } - this.performSearch(); + // Triggers the performSearch effect + setIsSearchLoading(true); }; /** * Turn loading false. * Useful to be used by autocomplete component when the first search doesn't find any token */ - loadingFinished = () => { - this.setState({ loading: false }); + const loadingFinished = () => { + setLoading(false); }; - render() { - if (this.state.maintenanceMode) { - return ( - - ); - } - - const renderSearchField = () => { - return ( - - ); - }; + // If this component is called in maintenance mode, no need to execute any other calculations + if (maintenanceMode) { + return ( + + ); + } - const renderTokensTable = () => { - if (this.state.error) { - return ; - } + const renderSearchField = () => { + return ( + + ); + }; - return ( - - ); - }; + const renderTokensTable = () => { + if (error) { + return ; + } return ( -
- {renderSearchField()} - -
- {this.state.tokenId !== hathorLibConstants.NATIVE_TOKEN_UID && ( -

- - Click here to see the token details - -

- )} - - {!this.state.tokenBalanceInformationError && ( -

- Total number of addresses:{' '} - {numberUtils.prettyValue(this.state.addressesCount, 0)} -

- )} - - {!this.state.tokensApiError && ( -

- Total number of transactions:{' '} - {numberUtils.prettyValue(this.state.transactionsCount, 0)} -

- )} - - {(this.state.tokensApiError || this.state.tokenBalanceInformationError) && ( - - )} -
- - {renderTokensTable()} -
+ ); - } + }; + + return ( +
+ {renderSearchField()} + +
+ {tokenId !== hathorLibConstants.NATIVE_TOKEN_UID && ( +

+ Click here to see the token details +

+ )} + + {!tokenBalanceInformationError && ( +

+ Total number of addresses: {numberUtils.prettyValue(addressesCount, 0)} +

+ )} + + {!tokensApiError && ( +

+ Total number of transactions: {numberUtils.prettyValue(transactionsCount, 0)} +

+ )} + + {(tokensApiError || tokenBalanceInformationError) && ( + + )} +
+ + {tokenId && renderTokensTable()} +
+ ); } /** @@ -380,4 +375,4 @@ TokenBalances.propTypes = { maintenanceMode: PropTypes.bool.isRequired, }; -export default withRouter(TokenBalances); +export default TokenBalances;