Add account details page

This commit is contained in:
Justin Starry 2020-05-12 18:32:14 +08:00 committed by Michael Vines
parent 59288117b9
commit 29ec98d0a1
7 changed files with 464 additions and 379 deletions

View File

@ -2,20 +2,19 @@ import React from "react";
import { Link, Switch, Route, Redirect } from "react-router-dom"; import { Link, Switch, Route, Redirect } from "react-router-dom";
import AccountsCard from "./components/AccountsCard"; import AccountsCard from "./components/AccountsCard";
import AccountDetails from "./components/AccountDetails";
import TransactionsCard from "./components/TransactionsCard"; import TransactionsCard from "./components/TransactionsCard";
import TransactionDetails from "./components/TransactionDetails"; import TransactionDetails from "./components/TransactionDetails";
import ClusterModal from "./components/ClusterModal"; import ClusterModal from "./components/ClusterModal";
import AccountModal from "./components/AccountModal";
import Logo from "./img/logos-solana/light-explorer-logo.svg"; import Logo from "./img/logos-solana/light-explorer-logo.svg";
import { TX_ALIASES } from "./providers/transactions"; import { TX_ALIASES } from "./providers/transactions";
import { ACCOUNT_PATHS } from "./providers/accounts"; import { ACCOUNT_ALIASES, ACCOUNT_ALIASES_PLURAL } from "./providers/accounts";
import TabbedPage from "components/TabbedPage"; import TabbedPage from "components/TabbedPage";
function App() { function App() {
return ( return (
<> <>
<ClusterModal /> <ClusterModal />
<AccountModal />
<div className="main-content"> <div className="main-content">
<nav className="navbar navbar-expand-xl navbar-light"> <nav className="navbar navbar-expand-xl navbar-light">
<div className="container"> <div className="container">
@ -44,7 +43,16 @@ function App() {
<TransactionsCard /> <TransactionsCard />
</TabbedPage> </TabbedPage>
</Route> </Route>
<Route path={ACCOUNT_PATHS}> <Route
exact
path={ACCOUNT_ALIASES.concat(ACCOUNT_ALIASES_PLURAL).map(
account => `/${account}/:address`
)}
render={({ match }) => (
<AccountDetails address={match.params.address} />
)}
/>
<Route exact path={ACCOUNT_ALIASES_PLURAL.map(alias => "/" + alias)}>
<TabbedPage tab="Accounts"> <TabbedPage tab="Accounts">
<AccountsCard /> <AccountsCard />
</TabbedPage> </TabbedPage>

View File

@ -0,0 +1,261 @@
import React from "react";
import { useClusterModal } from "providers/cluster";
import { PublicKey } from "@solana/web3.js";
import ClusterStatusButton from "components/ClusterStatusButton";
import { useHistory, useLocation } from "react-router-dom";
import {
Status,
useFetchAccountInfo,
useFetchAccountHistory,
useAccountInfo
} from "providers/accounts";
import { lamportsToSolString } from "utils";
import Copyable from "./Copyable";
import { displayAddress } from "utils/tx";
type Props = { address: string };
export default function AccountDetails({ address }: Props) {
const fetchAccount = useFetchAccountInfo();
const [, setShow] = useClusterModal();
const [search, setSearch] = React.useState(address);
const history = useHistory();
const location = useLocation();
let pubkey: PublicKey | undefined;
try {
pubkey = new PublicKey(address);
} catch (err) {
console.error(err);
// TODO handle bad addresses
}
const updateAddress = () => {
history.push({ ...location, pathname: "/account/" + search });
};
// Fetch account on load
React.useEffect(() => {
if (pubkey) fetchAccount(pubkey);
}, [pubkey?.toBase58()]); // eslint-disable-line react-hooks/exhaustive-deps
const searchInput = (
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyUp={e => e.key === "Enter" && updateAddress()}
className="form-control form-control-prepended search text-monospace"
placeholder="Search for address"
/>
);
return (
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center">
<div className="col">
<h6 className="header-pretitle">Details</h6>
<h3 className="header-title">Account</h3>
</div>
<div className="col-auto">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
<div className="row mb-4 mt-n2 align-items-center">
<div className="col d-none d-md-block">
<div className="input-group input-group-merge">
{searchInput}
<div className="input-group-prepend">
<div className="input-group-text">
<span className="fe fe-search"></span>
</div>
</div>
</div>
</div>
<div className="col d-block d-md-none">{searchInput}</div>
<div className="col-auto ml-n3 d-block d-md-none">
<button className="btn btn-white" onClick={updateAddress}>
<span className="fe fe-search"></span>
</button>
</div>
</div>
{pubkey && <InfoCard pubkey={pubkey} />}
{pubkey && <HistoryCard pubkey={pubkey} />}
</div>
);
}
function InfoCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const info = useAccountInfo(address);
const refresh = useFetchAccountInfo();
if (!info || info.status === Status.Checking) {
return <LoadingCard />;
} else if (
info.status === Status.CheckFailed ||
info.lamports === undefined
) {
return <RetryCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
}
const { details, lamports } = info;
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Overview</h3>
<button
className="btn btn-white btn-sm"
onClick={() => refresh(pubkey)}
>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</button>
</div>
<TableCardBody>
<tr>
<td>Balance (SOL)</td>
<td className="text-right text-uppercase">
{lamportsToSolString(lamports)}
</td>
</tr>
{details && (
<tr>
<td>Data (Bytes)</td>
<td className="text-right">{details.space}</td>
</tr>
)}
{details && (
<tr>
<td>Owner</td>
<td className="text-right">
<Copyable text={details.owner.toBase58()}>
<code>{displayAddress(details.owner)}</code>
</Copyable>
</td>
</tr>
)}
{details && (
<tr>
<td>Executable</td>
<td className="text-right">{details.executable ? "Yes" : "No"}</td>
</tr>
)}
</TableCardBody>
</div>
);
}
function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const info = useAccountInfo(address);
const refresh = useFetchAccountHistory();
if (!info || !info.details) {
return null;
} else if (info.status === Status.FetchingHistory) {
return <LoadingCard />;
} else if (info.history === undefined) {
return (
<RetryCard
retry={() => refresh(pubkey)}
text="Failed to fetch transaction history"
/>
);
}
if (info.history.size === 0) {
return (
<RetryCard
retry={() => refresh(pubkey)}
text="No transaction history found"
/>
);
}
const detailsList: React.ReactNode[] = [];
info.history.forEach((slotTransactions, slot) => {
const signatures = Array.from(slotTransactions.entries()).map(
([signature, err]) => {
return <code className="mb-2 mb-last-0">{signature}</code>;
}
);
detailsList.push(
<tr>
<td className="vertical-top">Slot {slot}</td>
<td className="text-right">
<div className="d-inline-flex flex-column align-items-end">
{signatures}
</div>
</td>
</tr>
);
});
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Transaction History</h3>
<button
className="btn btn-white btn-sm"
onClick={() => refresh(pubkey)}
>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</button>
</div>
<TableCardBody>{detailsList}</TableCardBody>
</div>
);
}
function LoadingCard() {
return (
<div className="card">
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
</div>
);
}
function RetryCard({ retry, text }: { retry: () => void; text: string }) {
return (
<div className="card">
<div className="card-body text-center">
{text}
<span className="btn btn-white ml-3 d-none d-md-inline" onClick={retry}>
Try Again
</span>
<div className="d-block d-md-none mt-4">
<hr></hr>
<span className="btn btn-white" onClick={retry}>
Try Again
</span>
</div>
</div>
</div>
);
}
function TableCardBody({ children }: { children: React.ReactNode }) {
return (
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<tbody className="list">{children}</tbody>
</table>
</div>
);
}

View File

@ -1,154 +0,0 @@
import React from "react";
import {
useSelectedAccount,
useAccountsDispatch,
ActionType,
Account,
Status
} from "../providers/accounts";
import { TransactionError } from "@solana/web3.js";
import Copyable from "./Copyable";
import Overlay from "./Overlay";
function AccountModal() {
const selected = useSelectedAccount();
const dispatch = useAccountsDispatch();
const onClose = () => dispatch({ type: ActionType.Select });
const show = !!selected;
const renderContent = () => {
if (!selected) return null;
return (
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-card card">
<div className="card-header">
<h4 className="card-header-title">Account Transaction History</h4>
<button type="button" className="close" onClick={onClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="card-body">
<AccountDetails account={selected} />
</div>
</div>
</div>
</div>
);
};
return (
<>
<div className={`modal fade${show ? " show" : ""}`} onClick={onClose}>
{renderContent()}
</div>
<Overlay show={show} />
</>
);
}
function AccountDetails({ account }: { account: Account }) {
const renderError = (content: React.ReactNode) => {
return <span className="text-info">{content}</span>;
};
if (account.status === Status.FetchingHistory) {
return renderError(
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
);
}
if (account.history === undefined)
return renderError("Failed to fetch account transaction history");
if (account.history.size === 0) return renderError("No transactions found");
const detailsList: React.ReactNode[] = [];
account.history.forEach((slotTransactions, slot) => {
detailsList.push(
<SlotTransactionDetails
slot={slot}
statuses={Array.from(slotTransactions.entries())}
/>
);
});
return (
<>
{detailsList.map((details, i) => {
return (
<React.Fragment key={++i}>
{i > 1 ? <hr className="mt-0 mx-n4 mb-4"></hr> : null}
{details}
</React.Fragment>
);
})}
</>
);
}
function SlotTransactionDetails({
slot,
statuses
}: {
slot: number;
statuses: [string, TransactionError | null][];
}) {
return (
<>
<h4 className="slot-pill">Slot #{slot}</h4>
<div className="list-group list-group-flush">
{statuses.map(([signature, err]) => {
return (
<ListGroupItem
key={signature}
signature={signature}
failed={err !== null}
/>
);
})}
</div>
</>
);
}
function ListGroupItem({
signature,
failed
}: {
signature: string;
failed: boolean;
}) {
let badgeText, badgeColor;
if (failed) {
badgeText = "Error";
badgeColor = "danger";
} else {
badgeText = "Success";
badgeColor = "primary";
}
return (
<div className="list-group-item slot-item">
<div className="row align-items-center justify-content-between flex-nowrap">
<div className="col-auto">
<span className={`badge badge-soft-${badgeColor} badge-pill`}>
{badgeText}
</span>
</div>
<div className="col min-width-0">
<Copyable text={signature}>
<h5 className="mb-0 text-truncate">
<code>{signature}</code>
</h5>
</Copyable>
</div>
</div>
</div>
);
}
export default AccountModal;

View File

@ -1,26 +1,22 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { import {
useAccounts, useAccounts,
useAccountsDispatch,
Dispatch,
fetchAccountInfo,
ActionType,
Account, Account,
Status Status,
useFetchAccountInfo
} from "../providers/accounts"; } from "../providers/accounts";
import { assertUnreachable } from "../utils"; import { assertUnreachable } from "../utils";
import { displayAddress } from "../utils/tx"; import { displayAddress } from "../utils/tx";
import { useCluster } from "../providers/cluster";
import { PublicKey } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js";
import Copyable from "./Copyable"; import Copyable from "./Copyable";
import { lamportsToSolString } from "utils"; import { lamportsToSolString } from "utils";
function AccountsCard() { function AccountsCard() {
const { accounts, idCounter } = useAccounts(); const { accounts, idCounter } = useAccounts();
const dispatch = useAccountsDispatch(); const fetchAccountInfo = useFetchAccountInfo();
const addressInput = React.useRef<HTMLInputElement>(null); const addressInput = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const { url } = useCluster();
const onNew = (address: string) => { const onNew = (address: string) => {
if (address.length === 0) return; if (address.length === 0) return;
@ -32,9 +28,7 @@ function AccountsCard() {
return; return;
} }
dispatch({ type: ActionType.Input, pubkey }); fetchAccountInfo(pubkey);
fetchAccountInfo(dispatch, address, url);
const inputEl = addressInput.current; const inputEl = addressInput.current;
if (inputEl) { if (inputEl) {
inputEl.value = ""; inputEl.value = "";
@ -91,7 +85,7 @@ function AccountsCard() {
<td>-</td> <td>-</td>
<td></td> <td></td>
</tr> </tr>
{accounts.map(account => renderAccountRow(account, dispatch, url))} {accounts.map(account => renderAccountRow(account))}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -111,11 +105,7 @@ const renderHeader = () => {
); );
}; };
const renderAccountRow = ( const renderAccountRow = (account: Account) => {
account: Account,
dispatch: Dispatch,
url: string
) => {
let statusText; let statusText;
let statusClass; let statusClass;
switch (account.status) { switch (account.status) {
@ -158,42 +148,6 @@ const renderAccountRow = (
balance = lamportsToSolString(account.lamports); balance = lamportsToSolString(account.lamports);
} }
const renderDetails = () => {
let onClick, icon;
switch (account.status) {
case Status.Success:
icon = "more-horizontal";
onClick = () =>
dispatch({
type: ActionType.Select,
address: account.pubkey.toBase58()
});
break;
case Status.CheckFailed:
case Status.HistoryFailed: {
icon = "refresh-cw";
onClick = () => {
fetchAccountInfo(dispatch, account.pubkey.toBase58(), url);
};
break;
}
default: {
return null;
}
}
return (
<button
className="btn btn-rounded-circle btn-white btn-sm"
onClick={onClick}
>
<span className={`fe fe-${icon}`}></span>
</button>
);
};
const base58AccountPubkey = account.pubkey.toBase58(); const base58AccountPubkey = account.pubkey.toBase58();
return ( return (
<tr key={account.id}> <tr key={account.id}>
@ -219,7 +173,17 @@ const renderAccountRow = (
</Copyable> </Copyable>
)} )}
</td> </td>
<td>{renderDetails()}</td> <td>
<Link
to={location => ({
...location,
pathname: "/account/" + base58AccountPubkey
})}
className="btn btn-rounded-circle btn-white btn-sm"
>
<span className="fe fe-arrow-right"></span>
</Link>
</td>
</tr> </tr>
); );
}; };

View File

@ -6,7 +6,7 @@ import {
TransactionError, TransactionError,
SignatureStatus SignatureStatus
} from "@solana/web3.js"; } from "@solana/web3.js";
import { findGetParameter, findPathSegment } from "../utils/url"; import { useQuery } from "../utils/url";
import { useCluster, ClusterStatus } from "./cluster"; import { useCluster, ClusterStatus } from "./cluster";
export enum Status { export enum Status {
@ -42,18 +42,16 @@ type Accounts = { [address: string]: Account };
interface State { interface State {
idCounter: number; idCounter: number;
accounts: Accounts; accounts: Accounts;
selected?: string;
} }
export enum ActionType { export enum ActionType {
Update, Update,
Input, Fetch
Select
} }
interface Update { interface Update {
type: ActionType.Update; type: ActionType.Update;
address: string; pubkey: PublicKey;
data: { data: {
status: Status; status: Status;
lamports?: number; lamports?: number;
@ -62,50 +60,53 @@ interface Update {
}; };
} }
interface Input { interface Fetch {
type: ActionType.Input; type: ActionType.Fetch;
pubkey: PublicKey; pubkey: PublicKey;
} }
interface Select { type Action = Update | Fetch;
type: ActionType.Select;
address?: string;
}
type Action = Update | Input | Select;
export type Dispatch = (action: Action) => void; export type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case ActionType.Input: { case ActionType.Fetch: {
const address = action.pubkey.toBase58(); const address = action.pubkey.toBase58();
if (!!state.accounts[address]) return state; const account = state.accounts[address];
const idCounter = state.idCounter + 1; if (account) {
const accounts = { const accounts = {
...state.accounts, ...state.accounts,
[address]: { [address]: {
id: idCounter, id: account.id,
status: Status.Checking, pubkey: account.pubkey,
pubkey: action.pubkey status: Status.Checking
} }
}; };
return { ...state, accounts, idCounter }; return { ...state, accounts };
} } else {
const idCounter = state.idCounter + 1;
case ActionType.Select: { const accounts = {
return { ...state, selected: action.address }; ...state.accounts,
[address]: {
id: idCounter,
status: Status.Checking,
pubkey: action.pubkey
}
};
return { ...state, accounts, idCounter };
}
} }
case ActionType.Update: { case ActionType.Update: {
let account = state.accounts[action.address]; const address = action.pubkey.toBase58();
const account = state.accounts[address];
if (account) { if (account) {
account = {
...account,
...action.data
};
const accounts = { const accounts = {
...state.accounts, ...state.accounts,
[action.address]: account [address]: {
...account,
...action.data
}
}; };
return { ...state, accounts }; return { ...state, accounts };
} }
@ -115,67 +116,49 @@ function reducer(state: State, action: Action): State {
return state; return state;
} }
export const ACCOUNT_PATHS = [ export const ACCOUNT_ALIASES = ["account", "address"];
"/account", export const ACCOUNT_ALIASES_PLURAL = ["accounts", "addresses"];
"/accounts",
"/address",
"/addresses"
];
function urlAddresses(): Array<string> {
const addresses: Array<string> = [];
ACCOUNT_PATHS.forEach(path => {
const name = path.slice(1);
const params = findGetParameter(name)?.split(",") || [];
const segments = findPathSegment(name)?.split(",") || [];
addresses.push(...params);
addresses.push(...segments);
});
return addresses.filter(a => a.length > 0);
}
function initState(): State {
let idCounter = 0;
const addresses = urlAddresses();
const accounts = addresses.reduce((accounts: Accounts, address) => {
if (!!accounts[address]) return accounts;
try {
const pubkey = new PublicKey(address);
const id = ++idCounter;
accounts[address] = {
id,
status: Status.Checking,
pubkey
};
} catch (err) {
// TODO display to user
console.error(err);
}
return accounts;
}, {});
return { idCounter, accounts };
}
const StateContext = React.createContext<State | undefined>(undefined); const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined); const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type AccountsProviderProps = { children: React.ReactNode }; type AccountsProviderProps = { children: React.ReactNode };
export function AccountsProvider({ children }: AccountsProviderProps) { export function AccountsProvider({ children }: AccountsProviderProps) {
const [state, dispatch] = React.useReducer(reducer, undefined, initState); const [state, dispatch] = React.useReducer(reducer, {
idCounter: 0,
accounts: {}
});
const { status, url } = useCluster(); const { status, url } = useCluster();
// Check account statuses on startup and whenever cluster updates // Check account statuses on startup and whenever cluster updates
React.useEffect(() => { React.useEffect(() => {
if (status !== ClusterStatus.Connected) return;
Object.keys(state.accounts).forEach(address => { Object.keys(state.accounts).forEach(address => {
fetchAccountInfo(dispatch, address, url); fetchAccountInfo(dispatch, new PublicKey(address), url, status);
}); });
}, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps }, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps
const query = useQuery();
const values = ACCOUNT_ALIASES.concat(ACCOUNT_ALIASES_PLURAL).map(key =>
query.get(key)
);
React.useEffect(() => {
values
.filter((value): value is string => value !== null)
.flatMap(value => value.split(","))
// Remove duplicates
.filter((item, pos, self) => self.indexOf(item) === pos)
.filter(address => !state.accounts[address])
.forEach(address => {
try {
fetchAccountInfo(dispatch, new PublicKey(address), url, status);
} catch (err) {
console.error(err);
// TODO handle bad addresses
}
});
}, [values.toString()]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}> <DispatchContext.Provider value={dispatch}>
@ -185,29 +168,28 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
); );
} }
export async function fetchAccountInfo( async function fetchAccountInfo(
dispatch: Dispatch, dispatch: Dispatch,
address: string, pubkey: PublicKey,
url: string url: string,
status: ClusterStatus
) { ) {
dispatch({ dispatch({
type: ActionType.Update, type: ActionType.Fetch,
address, pubkey
data: {
status: Status.Checking
}
}); });
let status; // We will auto-refetch when status is no longer connecting
if (status === ClusterStatus.Connecting) return;
let fetchStatus;
let details; let details;
let lamports; let lamports;
try { try {
const result = await new Connection(url, "recent").getAccountInfo( const result = await new Connection(url, "recent").getAccountInfo(pubkey);
new PublicKey(address)
);
if (result === null) { if (result === null) {
lamports = 0; lamports = 0;
status = Status.NotFound; fetchStatus = Status.NotFound;
} else { } else {
lamports = result.lamports; lamports = result.lamports;
details = { details = {
@ -215,29 +197,35 @@ export async function fetchAccountInfo(
executable: result.executable, executable: result.executable,
owner: result.owner owner: result.owner
}; };
status = Status.FetchingHistory; fetchStatus = Status.FetchingHistory;
fetchAccountHistory(dispatch, address, url); fetchAccountHistory(dispatch, pubkey, url);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch account info", error); console.error("Failed to fetch account info", error);
status = Status.CheckFailed; fetchStatus = Status.CheckFailed;
} }
const data = { status, lamports, details }; const data = { status: fetchStatus, lamports, details };
dispatch({ type: ActionType.Update, data, address }); dispatch({ type: ActionType.Update, data, pubkey });
} }
async function fetchAccountHistory( async function fetchAccountHistory(
dispatch: Dispatch, dispatch: Dispatch,
address: string, pubkey: PublicKey,
url: string url: string
) { ) {
dispatch({
type: ActionType.Update,
data: { status: Status.FetchingHistory },
pubkey
});
let history; let history;
let status; let status;
try { try {
const connection = new Connection(url); const connection = new Connection(url);
const currentSlot = await connection.getSlot(); const currentSlot = await connection.getSlot();
const signatures = await connection.getConfirmedSignaturesForAddress( const signatures = await connection.getConfirmedSignaturesForAddress(
new PublicKey(address), pubkey,
Math.max(0, currentSlot - 10000 + 1), Math.max(0, currentSlot - 10000 + 1),
currentSlot currentSlot
); );
@ -268,7 +256,7 @@ async function fetchAccountHistory(
status = Status.HistoryFailed; status = Status.HistoryFailed;
} }
const data = { status, history }; const data = { status, history };
dispatch({ type: ActionType.Update, data, address }); dispatch({ type: ActionType.Update, data, pubkey });
} }
export function useAccounts() { export function useAccounts() {
@ -284,16 +272,14 @@ export function useAccounts() {
}; };
} }
export function useSelectedAccount() { export function useAccountInfo(address: string) {
const context = React.useContext(StateContext); const context = React.useContext(StateContext);
if (!context) { if (!context) {
throw new Error( throw new Error(`useAccountInfo must be used within a AccountsProvider`);
`useSelectedAccount must be used within a AccountsProvider`
);
} }
if (!context.selected) return undefined; return context.accounts[address];
return context.accounts[context.selected];
} }
export function useAccountsDispatch() { export function useAccountsDispatch() {
@ -305,3 +291,31 @@ export function useAccountsDispatch() {
} }
return context; return context;
} }
export function useFetchAccountInfo() {
const dispatch = React.useContext(DispatchContext);
if (!dispatch) {
throw new Error(
`useFetchAccountInfo must be used within a AccountsProvider`
);
}
const { url, status } = useCluster();
return (pubkey: PublicKey) => {
fetchAccountInfo(dispatch, pubkey, url, status);
};
}
export function useFetchAccountHistory() {
const dispatch = React.useContext(DispatchContext);
if (!dispatch) {
throw new Error(
`useFetchAccountHistory must be used within a AccountsProvider`
);
}
const { url } = useCluster();
return (pubkey: PublicKey) => {
fetchAccountHistory(dispatch, pubkey, url);
};
}

View File

@ -4,7 +4,8 @@ import {
Connection, Connection,
SystemProgram, SystemProgram,
Account, Account,
SignatureResult SignatureResult,
PublicKey
} from "@solana/web3.js"; } from "@solana/web3.js";
import { useQuery } from "../../utils/url"; import { useQuery } from "../../utils/url";
import { useCluster, Cluster, ClusterStatus } from "../cluster"; import { useCluster, Cluster, ClusterStatus } from "../cluster";
@ -14,12 +15,7 @@ import {
DispatchContext as DetailsDispatchContext DispatchContext as DetailsDispatchContext
} from "./details"; } from "./details";
import base58 from "bs58"; import base58 from "bs58";
import { import { useFetchAccountInfo } from "../accounts";
useAccountsDispatch,
fetchAccountInfo,
Dispatch as AccountsDispatch,
ActionType as AccountsActionType
} from "../accounts";
export enum FetchStatus { export enum FetchStatus {
Fetching, Fetching,
@ -72,30 +68,41 @@ type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case ActionType.FetchSignature: { case ActionType.FetchSignature: {
if (!!state.transactions[action.signature]) return state; const transaction = state.transactions[action.signature];
const nextId = state.idCounter + 1;
const transactions = {
...state.transactions,
[action.signature]: {
id: nextId,
signature: action.signature,
fetchStatus: FetchStatus.Fetching
}
};
return { ...state, transactions, idCounter: nextId };
}
case ActionType.UpdateStatus: {
let transaction = state.transactions[action.signature];
if (transaction) { if (transaction) {
transaction = {
...transaction,
fetchStatus: action.fetchStatus,
info: action.info
};
const transactions = { const transactions = {
...state.transactions, ...state.transactions,
[action.signature]: transaction [action.signature]: {
...transaction,
fetchStatus: FetchStatus.Fetching,
info: undefined
}
};
return { ...state, transactions };
} else {
const nextId = state.idCounter + 1;
const transactions = {
...state.transactions,
[action.signature]: {
id: nextId,
signature: action.signature,
fetchStatus: FetchStatus.Fetching
}
};
return { ...state, transactions, idCounter: nextId };
}
}
case ActionType.UpdateStatus: {
const transaction = state.transactions[action.signature];
if (transaction) {
const transactions = {
...state.transactions,
[action.signature]: {
...transaction,
fetchStatus: action.fetchStatus,
info: action.info
}
}; };
return { ...state, transactions }; return { ...state, transactions };
} }
@ -118,23 +125,19 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
}); });
const { cluster, status: clusterStatus, url } = useCluster(); const { cluster, status: clusterStatus, url } = useCluster();
const accountsDispatch = useAccountsDispatch(); const fetchAccount = useFetchAccountInfo();
const query = useQuery(); const query = useQuery();
const testFlag = query.get("test"); const testFlag = query.get("test");
// Check transaction statuses whenever cluster updates // Check transaction statuses whenever cluster updates
React.useEffect(() => { React.useEffect(() => {
Object.keys(state.transactions).forEach(signature => { Object.keys(state.transactions).forEach(signature => {
dispatch({
type: ActionType.FetchSignature,
signature
});
fetchTransactionStatus(dispatch, signature, url, clusterStatus); fetchTransactionStatus(dispatch, signature, url, clusterStatus);
}); });
// Create a test transaction // Create a test transaction
if (cluster === Cluster.Devnet && testFlag !== null) { if (cluster === Cluster.Devnet && testFlag !== null) {
createTestTransaction(dispatch, accountsDispatch, url, clusterStatus); createTestTransaction(dispatch, fetchAccount, url, clusterStatus);
} }
}, [testFlag, cluster, clusterStatus, url]); // eslint-disable-line react-hooks/exhaustive-deps }, [testFlag, cluster, clusterStatus, url]); // eslint-disable-line react-hooks/exhaustive-deps
@ -151,10 +154,6 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
.filter((item, pos, self) => self.indexOf(item) === pos) .filter((item, pos, self) => self.indexOf(item) === pos)
.filter(signature => !state.transactions[signature]) .filter(signature => !state.transactions[signature])
.forEach(signature => { .forEach(signature => {
dispatch({
type: ActionType.FetchSignature,
signature
});
fetchTransactionStatus(dispatch, signature, url, clusterStatus); fetchTransactionStatus(dispatch, signature, url, clusterStatus);
}); });
}, [values.toString()]); // eslint-disable-line react-hooks/exhaustive-deps }, [values.toString()]); // eslint-disable-line react-hooks/exhaustive-deps
@ -170,7 +169,7 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
async function createTestTransaction( async function createTestTransaction(
dispatch: Dispatch, dispatch: Dispatch,
accountsDispatch: AccountsDispatch, fetchAccount: (pubkey: PublicKey) => void,
url: string, url: string,
clusterStatus: ClusterStatus clusterStatus: ClusterStatus
) { ) {
@ -187,16 +186,8 @@ async function createTestTransaction(
100000, 100000,
"recent" "recent"
); );
dispatch({
type: ActionType.FetchSignature,
signature
});
fetchTransactionStatus(dispatch, signature, url, clusterStatus); fetchTransactionStatus(dispatch, signature, url, clusterStatus);
accountsDispatch({ fetchAccount(testAccount.publicKey);
type: AccountsActionType.Input,
pubkey: testAccount.publicKey
});
fetchAccountInfo(accountsDispatch, testAccount.publicKey.toBase58(), url);
} catch (error) { } catch (error) {
console.error("Failed to create test success transaction", error); console.error("Failed to create test success transaction", error);
} }
@ -209,10 +200,6 @@ async function createTestTransaction(
lamports: 1 lamports: 1
}); });
const signature = await connection.sendTransaction(tx, testAccount); const signature = await connection.sendTransaction(tx, testAccount);
dispatch({
type: ActionType.FetchSignature,
signature
});
fetchTransactionStatus(dispatch, signature, url, clusterStatus); fetchTransactionStatus(dispatch, signature, url, clusterStatus);
} catch (error) { } catch (error) {
console.error("Failed to create test failure transaction", error); console.error("Failed to create test failure transaction", error);
@ -226,9 +213,8 @@ export async function fetchTransactionStatus(
status: ClusterStatus status: ClusterStatus
) { ) {
dispatch({ dispatch({
type: ActionType.UpdateStatus, type: ActionType.FetchSignature,
signature, signature
fetchStatus: FetchStatus.Fetching
}); });
// We will auto-refetch when status is no longer connecting // We will auto-refetch when status is no longer connecting
@ -328,10 +314,6 @@ export function useFetchTransactionStatus() {
const { url, status } = useCluster(); const { url, status } = useCluster();
return (signature: TransactionSignature) => { return (signature: TransactionSignature) => {
dispatch({
type: ActionType.FetchSignature,
signature
});
fetchTransactionStatus(dispatch, signature, url, status); fetchTransactionStatus(dispatch, signature, url, status);
}; };
} }

View File

@ -117,3 +117,13 @@ h4.slot-pill {
.min-width-0 { .min-width-0 {
min-width: 0; min-width: 0;
} }
.mb-last-0 {
&:last-child {
margin-bottom: 0 !important;
}
}
.vertical-top {
vertical-align: top !important;
}