Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add list pagination to the nano contract history table #292

Merged
merged 11 commits into from
Aug 20, 2024
14 changes: 13 additions & 1 deletion src/api/nanoApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,23 @@ const nanoApi = {
* Get the history of transactions of a nano contract
*
* @param {string} id Nano contract id
* @param {number | null} count Number of elements to get the history
* @param {string | null} after Hash of the tx to get as reference for after pagination
* @param {string | null} before Hash of the tx to get as reference for before pagination
*
* For more details, see full node api docs
*/
getHistory(id) {
getHistory(id, count, after, before) {
const data = { id };
if (count) {
data.count = count;
}
if (after) {
data.after = after;
}
if (before) {
data.before = before;
}
return requestExplorerServiceV1.get(`node_api/nc_history`, { params: data }).then(
res => {
return res.data;
Expand Down
267 changes: 267 additions & 0 deletions src/components/nano/NanoContractHistory.js
tuliomir marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import hathorLib from '@hathor/wallet-lib';
import { reverse } from 'lodash';
import Loading from '../Loading';
import { NANO_CONTRACT_TX_HISTORY_COUNT } from '../../constants';
import TxRow from '../tx/TxRow';
import helpers from '../../utils/helpers';
import nanoApi from '../../api/nanoApi';
import WebSocketHandler from '../../WebSocketHandler';
import PaginationURL from '../../utils/pagination';

/**
* Displays nano tx history in a table with pagination buttons. As the user navigates through the history,
* the URL parameters 'hash' and 'page' are updated.
*
* Either all URL parameters are set or they are all missing.
*
* Example 1:
* hash = "00000000001b328fafb336b4515bb9557733fe93cf685dfd0c77cae3131f3fff"
* page = "previous"
*
* Example 2:
* hash = "00000000001b328fafb336b4515bb9557733fe93cf685dfd0c77cae3131f3fff"
* page = "next"
*/
function NanoContractHistory({ ncId }) {
// We must use memo here because we were creating a new pagination
// object in every new render, so the useEffect was being called forever
const pagination = useMemo(
() =>
new PaginationURL({
hash: { required: false },
page: { required: false },
}),
[]
);

const location = useLocation();

// loading {boolean} Bool to show/hide loading element
const [loading, setLoading] = useState(true);
// history {Array} Nano contract history
const [history, setHistory] = useState([]);
// errorMessage {string} Message to show when error happens on history load
const [errorMessage, setErrorMessage] = useState('');
// hasBefore {boolean} If 'Previous' button should be enabled
const [hasBefore, setHasBefore] = useState(false);
// hasAfter {boolean} If 'Next' button should be enabled
const [hasAfter, setHasAfter] = useState(false);

/**
* useCallback is important here to update this method with new history state
* otherwise it would be fixed the moment the event listener is started in the useEffect
* with the history as an empty array
*
* @param {Transaction} tx Transaction object that arrived from the websocket
*/
const updateListWs = useCallback(
tx => {
// We only add to the list if it's the first page and it's a new tx from this nano
if (hasBefore) {
return;
}

if (tx.version !== hathorLib.constants.NANO_CONTRACTS_VERSION || tx.nc_id !== ncId) {
return;
}

let nanoHistory = [...history];
const willHaveAfter = hasAfter || nanoHistory.length === NANO_CONTRACT_TX_HISTORY_COUNT;
// This updates the list with the new element at first
nanoHistory = helpers.updateListWs(nanoHistory, tx, NANO_CONTRACT_TX_HISTORY_COUNT);

// Now update the history
setHistory(nanoHistory);
setHasAfter(willHaveAfter);
},
[history, hasAfter, hasBefore, ncId]
);

/**
* useCallback is needed here because this method is used as a dependency in the useEffect
*
* @param {string | null} after Hash to use for pagination when user clicks to fetch the next page
* @param {string | null} before Hash to use for pagination when user clicks to fetch the previous page
*/
const loadData = useCallback(
async (after, before) => {
try {
const data = await nanoApi.getHistory(ncId, NANO_CONTRACT_TX_HISTORY_COUNT, after, before);
if (before) {
// When we are querying the previous set of transactions
// the API return the oldest first, so we need to revert the history
reverse(data.history);
}
setHistory(data.history);

if (!after && !before) {
// This is the first load without query params, so if has_more === true
// we must enable next button
setHasAfter(data.has_more);
setHasBefore(false);
return;
}

if (after) {
// We clicked the next button, so we have before page
// and we will have the next page if has_more === true
setHasAfter(data.has_more);
setHasBefore(true);
return;
}

if (before) {
// We clicked the previous button, so we have next page
// and we will have the previous page if has_more === true
setHasAfter(true);
setHasBefore(data.has_more);
if (!data.has_more) {
// We are in the first page and clicked the Previous button
// so we must clear the query params
pagination.clearOptionalQueryParams();
}
return;
}
} catch (e) {
// Error in request
setErrorMessage('Error getting nano contract history.');
} finally {
setLoading(false);
}
},
[ncId, pagination]
);

/**
* useCallback is needed here because this method is used as a dependency in the useEffect
*
* Method to handle websocket messages that arrive in the network scope
* This method will discard any messages that are not new transactions
*
* wsData {Object} Data send in the websocket message
*/
const handleWebsocket = useCallback(
wsData => {
if (wsData.type === 'network:new_tx_accepted') {
updateListWs(wsData);
}
},
[updateListWs]
);

useEffect(() => {
// Handle load history depending on the query params in the URL
const queryParams = pagination.obtainQueryParams();
let after = null;
let before = null;
if (queryParams.hash) {
if (queryParams.page === 'previous') {
before = queryParams.hash;
} else if (queryParams.page === 'next') {
after = queryParams.hash;
} else {
// Params are wrong
pagination.clearOptionalQueryParams();
}
}

loadData(after, before);
}, [location, loadData, pagination]);

useEffect(() => {
// Handle new txs in the network to update the list in real time
WebSocketHandler.on('network', handleWebsocket);

return () => {
WebSocketHandler.removeListener('network', handleWebsocket);
};
}, [handleWebsocket]);

if (errorMessage) {
return <p className="text-danger mb-4">{errorMessage}</p>;
}

if (loading) {
return <Loading />;
}

const loadTable = () => {
return (
<div className="table-responsive mt-5">
<table className="table table-striped" id="tx-table">
<thead>
<tr>
<th className="d-none d-lg-table-cell">Hash</th>
<th className="d-none d-lg-table-cell">Timestamp</th>
<th className="d-table-cell d-lg-none" colSpan="2">
Hash
<br />
Timestamp
</th>
</tr>
</thead>
<tbody>{loadTableBody()}</tbody>
</table>
</div>
);
};

const loadTableBody = () => {
return history.map(tx => {
// For some reason this API returns tx.hash instead of tx.tx_id like the others
const rowTx = { ...tx };
rowTx.tx_id = rowTx.hash;
return <TxRow key={rowTx.tx_id} tx={rowTx} />;
});
};

const loadPagination = () => {
if (history.length === 0) {
return null;
}
return (
<nav aria-label="nano history tx pagination" className="d-flex justify-content-center">
<ul className="pagination">
<li
className={
!hasBefore || history.length === 0 ? 'page-item mr-3 disabled' : 'page-item mr-3'
}
>
<Link
className="page-link"
to={pagination.setURLParameters({ hash: history[0].hash, page: 'previous' })}
>
Previous
</Link>
</li>
<li className={!hasAfter || history.length === 0 ? 'page-item disabled' : 'page-item'}>
<Link
className="page-link"
to={pagination.setURLParameters({ hash: history.slice(-1).pop().hash, page: 'next' })}
>
Next
</Link>
</li>
</ul>
</nav>
);
};

return (
<div className="w-100">
{loadTable()}
{loadPagination()}
</div>
);
}

export default NanoContractHistory;
3 changes: 3 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,6 @@ export const UNLEASH_TIME_SERIES_FEATURE_FLAG = `explorer-timeseries-${REACT_APP
export const { REACT_APP_TIMESERIES_DASHBOARD_ID } = process.env;
export const TIMESERIES_DASHBOARD_URL = `https://hathor-explorer-75a9f9.kb.eu-central-1.aws.cloud.es.io:9243/s/anonymous-user/app/dashboards?auth_provider_hint=anonymous1#/view/${REACT_APP_TIMESERIES_DASHBOARD_ID}?embed=true&_g=(filters%3A!()%2CrefreshInterval%3A(pause%3A!t%2Cvalue%3A0)%2Ctime%3A(from%3Anow-1w%2Cto%3Anow))&show-time-filter=true&hide-filter-bar=true`;
export const SCREEN_STATUS_LOOP_INTERVAL_IN_SECONDS = 60; // This is the interval that ElasticSearch takes to ingest data from blocks

// Number of elements in the nano contract transaction history table
export const NANO_CONTRACT_TX_HISTORY_COUNT = 5;
Loading