Feat/122 next previous buttons (#131)
* fix use fetch hook * add next/previous buttons * disable the button if the block is 1 * prevent slow fetches from overriding data * move splash loader into UI toolkit * remove splash loader * remove splash * remove pointless component * add tests for blocks page * fix jump to party * merge updates * address PR comments
This commit is contained in:
parent
47e703c558
commit
c5788fa1cf
@ -1 +0,0 @@
|
||||
export * from "./splash-loader";
|
@ -1,22 +0,0 @@
|
||||
@import "../../styles/colors";
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&__animation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
div {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import './splash-loader.scss';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const SplashLoader = ({ text = 'Loading' }: { text?: string }) => {
|
||||
const [, forceRender] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
forceRender((x) => !x);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="loading" data-testid="splash-loader">
|
||||
<div className="loading__animation">
|
||||
{new Array(25).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
opacity: Math.random() > 0.75 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from "./splash-screen";
|
@ -1,12 +0,0 @@
|
||||
@import "../../styles/colors";
|
||||
|
||||
.splash-screen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 20px;
|
||||
color: $white;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import './splash-screen.scss';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const SplashScreen = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="splash-screen">{children}</div>;
|
||||
};
|
@ -1,10 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
import React, { useState } from 'react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
import { SplashLoader } from "../../components/splash-loader";
|
||||
import { SplashScreen } from "../../components/splash-screen";
|
||||
import { DATA_SOURCES } from "../../config";
|
||||
import { TendermintWebsocketContext } from "./tendermint-websocket-context";
|
||||
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import { DATA_SOURCES } from '../../config';
|
||||
import { TendermintWebsocketContext } from './tendermint-websocket-context';
|
||||
|
||||
/**
|
||||
* Provides a single, shared, websocket instance to the entire app to prevent recreation on every render
|
||||
@ -19,9 +18,9 @@ export const TendermintWebsocketProvider = ({
|
||||
|
||||
if (!contextShape) {
|
||||
return (
|
||||
<SplashScreen>
|
||||
<SplashLoader />
|
||||
</SplashScreen>
|
||||
<Splash>
|
||||
<Loader />
|
||||
</Splash>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -18,12 +18,12 @@ type Action<T> =
|
||||
| { type: ActionType.FETCHED; payload: T }
|
||||
| { type: ActionType.ERROR; error: Error };
|
||||
|
||||
function useFetch<T = unknown>(
|
||||
url?: string,
|
||||
function useFetch<T>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): { state: State<T>; refetch: () => void } {
|
||||
// Used to prevent state update if the component is unmounted
|
||||
const cancelRequest = useRef<boolean>(false);
|
||||
const cancelRequest = useRef<{ [key: string]: boolean }>({ [url]: false });
|
||||
|
||||
const initialState: State<T> = {
|
||||
error: undefined,
|
||||
@ -61,11 +61,11 @@ function useFetch<T = unknown>(
|
||||
// @ts-ignore - data.error
|
||||
throw new Error(data.error);
|
||||
}
|
||||
if (cancelRequest.current) return;
|
||||
if (cancelRequest.current[url]) return;
|
||||
|
||||
dispatch({ type: ActionType.FETCHED, payload: data });
|
||||
} catch (error) {
|
||||
if (cancelRequest.current) return;
|
||||
if (cancelRequest.current[url]) return;
|
||||
|
||||
dispatch({ type: ActionType.ERROR, error: error as Error });
|
||||
}
|
||||
@ -78,13 +78,15 @@ function useFetch<T = unknown>(
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
const cancel = cancelRequest.current;
|
||||
cancel[url] = false;
|
||||
fetchCallback();
|
||||
// Use the cleanup function for avoiding a possibly...
|
||||
// ...state update after the component was unmounted
|
||||
return () => {
|
||||
cancelRequest.current = true;
|
||||
cancel[url] = true;
|
||||
};
|
||||
}, [fetchCallback]);
|
||||
}, [fetchCallback, url]);
|
||||
|
||||
return {
|
||||
state,
|
||||
|
181
apps/explorer/src/app/routes/blocks/id/block.spec.tsx
Normal file
181
apps/explorer/src/app/routes/blocks/id/block.spec.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { Block } from './block';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Routes as RouteNames } from '../../router-config';
|
||||
|
||||
const blockId = 1085890;
|
||||
|
||||
const createBlockResponse = () => {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: -1,
|
||||
result: {
|
||||
block_id: {
|
||||
hash: '26D92E4B0C892AC6EF33A281185E612671976AD9C629F6C8182C1D92C2CE2F6F',
|
||||
parts: {
|
||||
total: 1,
|
||||
hash: 'D18AEBADEAADA2C701FFFAD5A16EE8C493C5D1B54589FD1587EA5C61B7179AAC',
|
||||
},
|
||||
},
|
||||
block: {
|
||||
header: {
|
||||
version: {
|
||||
block: '11',
|
||||
app: '1',
|
||||
},
|
||||
chain_id: 'testnet-12cd7b',
|
||||
height: '1085891',
|
||||
time: '2022-03-24T11:03:40.014303953Z',
|
||||
last_block_id: {
|
||||
hash: 'C50CA169545AC1280220433D7971C50D941F675E9B0FFF358ABE8F3A7F74AE0E',
|
||||
parts: {
|
||||
total: 1,
|
||||
hash: '86974C6359B39084235EE31C1389DEA052E01E552CD1D113B3222A63A8DF390C',
|
||||
},
|
||||
},
|
||||
last_commit_hash:
|
||||
'D8FBE7DEB393D740B22EF8E91DA426494E2535902A6FB89B1D754F0DAF74DB37',
|
||||
data_hash:
|
||||
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
|
||||
validators_hash:
|
||||
'2BC96D9FD4A7663A270909F7E604C24E1F8C87605F913F6DA55AF2DDE023BAC9',
|
||||
next_validators_hash:
|
||||
'2BC96D9FD4A7663A270909F7E604C24E1F8C87605F913F6DA55AF2DDE023BAC9',
|
||||
consensus_hash:
|
||||
'048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F',
|
||||
app_hash:
|
||||
'B04B71A61C9970A132631FFBA50E36B9C5A8A490983E803F6295133C255D3FCE',
|
||||
last_results_hash:
|
||||
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
|
||||
evidence_hash:
|
||||
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
|
||||
proposer_address: '1C9B6E2708F8217F8D5BFC8D8734ED9A5BC19B21',
|
||||
},
|
||||
data: {
|
||||
txs: [],
|
||||
},
|
||||
evidence: {
|
||||
evidence: [],
|
||||
},
|
||||
last_commit: {
|
||||
height: blockId.toString(),
|
||||
round: 0,
|
||||
block_id: {
|
||||
hash: 'C50CA169545AC1280220433D7971C50D941F675E9B0FFF358ABE8F3A7F74AE0E',
|
||||
parts: {
|
||||
total: 1,
|
||||
hash: '86974C6359B39084235EE31C1389DEA052E01E552CD1D113B3222A63A8DF390C',
|
||||
},
|
||||
},
|
||||
signatures: [
|
||||
{
|
||||
block_id_flag: 2,
|
||||
validator_address: '1C9B6E2708F8217F8D5BFC8D8734ED9A5BC19B21',
|
||||
timestamp: '2022-03-24T11:03:40.026173466Z',
|
||||
signature:
|
||||
'/BbNDfNflmhL5eNmpijxjjuLV8WJ1SkoesIThcpvxSjUhf+8tjZ+mIUkXig7xD5JB/7X23l6eEsbrBLxG6ppBA==',
|
||||
},
|
||||
{
|
||||
block_id_flag: 2,
|
||||
validator_address: '31D6EBD2A8E40524142613A241CA1D2056159EF4',
|
||||
timestamp: '2022-03-24T11:03:40.014303953Z',
|
||||
signature:
|
||||
'zJ717hzAyUN0qdfjtXHHQP05oKeGPSL5HOZ8syU6M0kj3C5fuP+IG6PdVHj26ZKthTyRhEyHcMBJ/FHu2s5MBw==',
|
||||
},
|
||||
{
|
||||
block_id_flag: 2,
|
||||
validator_address: '6DB7E2A705ABF86C6B4A4817E778669D45421166',
|
||||
timestamp: '2022-03-24T11:03:39.991116117Z',
|
||||
signature:
|
||||
'lRwyqUnIBqyyL9XHfgTdfABVT3B3T9aIb7HP656TcqOf1d1hmnZ8oZGXeKc5SNpssJSlHl9V/F9k2LZtHChKBg==',
|
||||
},
|
||||
{
|
||||
block_id_flag: 2,
|
||||
validator_address: 'A5429AF24A820AFD9C3D21507C8642F27F5DD308',
|
||||
timestamp: '2022-03-24T11:03:39.988302733Z',
|
||||
signature:
|
||||
'ARjFOJger/wlBwMap3DaMhYKe9ywkQg/rxVCLZ0MMwdhAkviC8gvZRwoDajbKuYgbsgG1MwsGk/mEib5O5cBBA==',
|
||||
},
|
||||
{
|
||||
block_id_flag: 2,
|
||||
validator_address: 'AE5B9A8193AEFC405C159C930ED2BBF40A806785',
|
||||
timestamp: '2022-03-24T11:03:40.020448546Z',
|
||||
signature:
|
||||
'o2z4gdBiNUskFQ4m/yb+uM0/jaOf1p6jpGlKoEhebn2ExreaayN/JJR8F98uWk1M4S0zK9trI9oWDgwmxo5CAg==',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[`/${RouteNames.BLOCKS}/${blockId}`]}>
|
||||
<Routes>
|
||||
<Route path={`/${RouteNames.BLOCKS}/:block`} element={<Block />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(createBlockResponse()),
|
||||
})
|
||||
) as jest.Mock;
|
||||
jest.useFakeTimers().setSystemTime(1648123348642);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Block', () => {
|
||||
it('should render title, proposer address and time mined', async () => {
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(createBlockResponse()),
|
||||
})
|
||||
) as jest.Mock;
|
||||
render(renderComponent());
|
||||
await waitFor(() => screen.getByTestId('block-header'));
|
||||
|
||||
expect(screen.getByTestId('block-header')).toHaveTextContent(
|
||||
`BLOCK ${blockId}`
|
||||
);
|
||||
const proposer = screen.getByTestId('block-validator');
|
||||
expect(proposer).toHaveTextContent(
|
||||
'1C9B6E2708F8217F8D5BFC8D8734ED9A5BC19B21'
|
||||
);
|
||||
expect(proposer).toHaveAttribute('href', `/${RouteNames.VALIDATORS}`);
|
||||
expect(screen.getByTestId('block-time')).toHaveTextContent(
|
||||
'3528 seconds ago'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders next and previous buttons', async () => {
|
||||
render(renderComponent());
|
||||
await waitFor(() => screen.getByTestId('block-header'));
|
||||
|
||||
expect(screen.getByTestId('previous-block')).toHaveAttribute(
|
||||
'href',
|
||||
`/${RouteNames.BLOCKS}/${blockId - 1}`
|
||||
);
|
||||
expect(screen.getByTestId('next-block')).toHaveAttribute(
|
||||
'href',
|
||||
`/${RouteNames.BLOCKS}/${blockId + 1}`
|
||||
);
|
||||
});
|
||||
});
|
88
apps/explorer/src/app/routes/blocks/id/block.tsx
Normal file
88
apps/explorer/src/app/routes/blocks/id/block.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { DATA_SOURCES } from '../../../config';
|
||||
import useFetch from '../../../hooks/use-fetch';
|
||||
import { TendermintBlocksResponse } from '../tendermint-blocks-response';
|
||||
import { RouteTitle } from '../../../components/route-title';
|
||||
import { SecondsAgo } from '../../../components/seconds-ago';
|
||||
import {
|
||||
Table,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
} from '../../../components/table';
|
||||
import { TxsPerBlock } from '../../../components/txs/txs-per-block';
|
||||
import { Button } from '@vegaprotocol/ui-toolkit';
|
||||
import { Routes } from '../../router-config';
|
||||
import { RenderFetched } from '../../../components/render-fetched';
|
||||
|
||||
const Block = () => {
|
||||
const { block } = useParams<{ block: string }>();
|
||||
const {
|
||||
state: { data: blockData, loading, error },
|
||||
} = useFetch<TendermintBlocksResponse>(
|
||||
`${DATA_SOURCES.tendermintUrl}/block?height=${block}`
|
||||
);
|
||||
|
||||
const header = blockData?.result.block.header;
|
||||
if (!header) {
|
||||
return <p>Could not get block data</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<RouteTitle data-testid="block-header">BLOCK {block}</RouteTitle>
|
||||
<RenderFetched error={error} loading={loading}>
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-16">
|
||||
<Link
|
||||
data-testid="previous-block"
|
||||
to={`/${Routes.BLOCKS}/${Number(block) - 1}`}
|
||||
>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={Number(block) === 1}
|
||||
variant="secondary"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
data-testid="next-block"
|
||||
to={`/${Routes.BLOCKS}/${Number(block) + 1}`}
|
||||
>
|
||||
<Button className="w-full" variant="secondary">
|
||||
Next
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Table className="mb-28">
|
||||
<TableRow modifier="bordered">
|
||||
<TableHeader scope="row">Mined by</TableHeader>
|
||||
<TableCell modifier="bordered">
|
||||
<Link
|
||||
data-testid="block-validator"
|
||||
className="text-vega-yellow font-mono"
|
||||
to={`/${Routes.VALIDATORS}`}
|
||||
>
|
||||
{header.proposer_address}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow modifier="bordered">
|
||||
<TableHeader scope="row">Time</TableHeader>
|
||||
<TableCell modifier="bordered">
|
||||
<SecondsAgo data-testid="block-time" date={header.time} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Table>
|
||||
{blockData && blockData.result.block.data.txs.length > 0 ? (
|
||||
<TxsPerBlock blockHeight={block} />
|
||||
) : null}
|
||||
</>
|
||||
</RenderFetched>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Block };
|
@ -1,60 +1 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { DATA_SOURCES } from '../../../config';
|
||||
import useFetch from '../../../hooks/use-fetch';
|
||||
import { TendermintBlocksResponse } from '../tendermint-blocks-response';
|
||||
import { RouteTitle } from '../../../components/route-title';
|
||||
import { SecondsAgo } from '../../../components/seconds-ago';
|
||||
import {
|
||||
Table,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
} from '../../../components/table';
|
||||
import { TxsPerBlock } from '../../../components/txs/txs-per-block';
|
||||
|
||||
const Block = () => {
|
||||
const { block } = useParams<{ block: string }>();
|
||||
const {
|
||||
state: { data: blockData },
|
||||
} = useFetch<TendermintBlocksResponse>(
|
||||
`${DATA_SOURCES.tendermintUrl}/block?height=${block}`
|
||||
);
|
||||
|
||||
const header = blockData?.result.block.header;
|
||||
|
||||
if (!header) {
|
||||
return <>Could not get block data</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<RouteTitle>BLOCK {block}</RouteTitle>
|
||||
<Table className="mb-28">
|
||||
<TableRow modifier="bordered">
|
||||
<TableHeader scope="row">Mined by</TableHeader>
|
||||
<TableCell modifier="bordered">
|
||||
<Link
|
||||
data-testid="block-validator"
|
||||
className="text-vega-yellow font-mono"
|
||||
to={'/validators'}
|
||||
>
|
||||
{header.proposer_address}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow modifier="bordered">
|
||||
<TableHeader scope="row">Time</TableHeader>
|
||||
<TableCell modifier="bordered">
|
||||
<SecondsAgo data-testid="block-time" date={header.time} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Table>
|
||||
{blockData?.result.block.data.txs.length > 0 && (
|
||||
<TxsPerBlock blockHeight={block} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Block };
|
||||
export * from './block';
|
||||
|
@ -2,9 +2,8 @@ import React from 'react';
|
||||
import { useRoutes } from 'react-router-dom';
|
||||
import { RouteErrorBoundary } from '../components/router-error-boundary';
|
||||
|
||||
import { SplashLoader } from '../components/splash-loader';
|
||||
import { SplashScreen } from '../components/splash-screen';
|
||||
import routerConfig from './router-config';
|
||||
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
export interface RouteChildProps {
|
||||
name: string;
|
||||
@ -14,9 +13,9 @@ export const AppRouter = () => {
|
||||
const routes = useRoutes(routerConfig);
|
||||
|
||||
const splashLoading = (
|
||||
<SplashScreen>
|
||||
<SplashLoader />
|
||||
</SplashScreen>
|
||||
<Splash>
|
||||
<Loader />
|
||||
</Splash>
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -7,23 +7,19 @@ import { Routes } from '../../router-config';
|
||||
export const JumpToParty = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
() => (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
partyId: { value: number };
|
||||
};
|
||||
const target = e.target as typeof e.target & {
|
||||
partyId: { value: number };
|
||||
};
|
||||
|
||||
const partyId = target.partyId.value;
|
||||
|
||||
if (partyId) {
|
||||
navigate(`/${Routes.PARTIES}/${partyId}`);
|
||||
}
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const partyId = target.partyId.value;
|
||||
|
||||
if (partyId) {
|
||||
navigate(`/${Routes.PARTIES}/${partyId}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label
|
||||
|
@ -12,18 +12,20 @@ export const Loader = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="flex flex-wrap w-[15px] h-[15px]">
|
||||
{new Array(9).fill(null).map((_, i) => {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="block w-[5px] h-[5px] bg-black dark:bg-white"
|
||||
style={{
|
||||
opacity: Math.random() > 0.5 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<div className="flex flex-col items-center" data-testid="splash-loader">
|
||||
<div className="w-64 h-64 flex flex-wrap">
|
||||
{new Array(16).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-16 dark:bg-white bg-black"
|
||||
key={i}
|
||||
style={{
|
||||
opacity: Math.random() > 0.75 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user