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:
Dexter Edwards 2022-03-29 14:30:23 +01:00 committed by GitHub
parent 47e703c558
commit c5788fa1cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 316 additions and 183 deletions

View File

@ -1 +0,0 @@
export * from "./splash-loader";

View File

@ -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;
}
}
}

View File

@ -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>
);
};

View File

@ -1 +0,0 @@
export * from "./splash-screen";

View File

@ -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;
}

View File

@ -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>;
};

View File

@ -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>
);
}

View File

@ -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,

View 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}`
);
});
});

View 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 };

View File

@ -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';

View File

@ -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 (

View File

@ -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

View File

@ -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>
);
};