Split explorer account details page into tabs (#11450)

This commit is contained in:
Justin Starry 2020-08-08 00:38:20 +08:00 committed by GitHub
parent bad486823c
commit 67fdf593a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 164 additions and 110 deletions

View File

@ -5,7 +5,6 @@ import AccountDetails from "./components/AccountDetails";
import TransactionDetails from "./components/TransactionDetails";
import ClusterModal from "./components/ClusterModal";
import { TX_ALIASES } from "./providers/transactions";
import { ACCOUNT_ALIASES, ACCOUNT_ALIASES_PLURAL } from "./providers/accounts";
import TopAccountsCard from "components/TopAccountsCard";
import SupplyCard from "components/SupplyCard";
import StatsCard from "components/StatsCard";
@ -14,6 +13,8 @@ import Navbar from "components/Navbar";
import { ClusterStatusBanner } from "components/ClusterStatusButton";
import { SearchBar } from "components/SearchBar";
const ACCOUNT_ALIASES = ["account", "accounts", "addresses"];
function App() {
return (
<>
@ -41,11 +42,26 @@ function App() {
/>
<Route
exact
path={ACCOUNT_ALIASES.concat(ACCOUNT_ALIASES_PLURAL).map(
(account) => `/${account}/:address`
)}
path={ACCOUNT_ALIASES.flatMap((account) => [
`/${account}/:address`,
`/${account}/:address/:tab`,
])}
render={({ match, location }) => {
let pathname = `/address/${match.params.address}`;
if (match.params.tab) {
pathname += `/${match.params.tab}`;
}
return <Redirect to={{ ...location, pathname }} />;
}}
/>
<Route
exact
path={["/address/:address", "/address/:address/:tab"]}
render={({ match }) => (
<AccountDetails address={match.params.address} />
<AccountDetails
address={match.params.address}
tab={match.params.tab}
/>
)}
/>
<Route exact path="/">

View File

@ -21,9 +21,11 @@ import {
import { useCluster, ClusterStatus } from "providers/cluster";
import Address from "./common/Address";
import Signature from "./common/Signature";
import { NavLink } from "react-router-dom";
import { clusterPath } from "utils/url";
type Props = { address: string };
export default function AccountDetails({ address }: Props) {
type Props = { address: string; tab?: string };
export default function AccountDetails({ address, tab }: Props) {
let pubkey: PublicKey | undefined;
try {
pubkey = new PublicKey(address);
@ -32,6 +34,11 @@ export default function AccountDetails({ address }: Props) {
// TODO handle bad addresses
}
let moreTab: MoreTabs = "history";
if (tab === "history" || tab === "tokens") {
moreTab = tab;
}
return (
<div className="container mt-n3">
<div className="header">
@ -41,12 +48,51 @@ export default function AccountDetails({ address }: Props) {
</div>
</div>
{pubkey && <AccountCards pubkey={pubkey} />}
{pubkey && <TokensCard pubkey={pubkey} />}
{pubkey && <HistoryCard pubkey={pubkey} />}
{pubkey && <MoreSection pubkey={pubkey} tab={moreTab} />}
</div>
);
}
type MoreTabs = "history" | "tokens";
function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
const address = pubkey.toBase58();
const info = useAccountInfo(address);
if (!info || info.lamports === undefined) return null;
return (
<>
<div className="container">
<div className="header">
<div className="header-body pt-0">
<ul className="nav nav-tabs nav-overflow header-tabs">
<li className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}`)}
exact
>
History
</NavLink>
</li>
<li className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}/tokens`)}
exact
>
Tokens
</NavLink>
</li>
</ul>
</div>
</div>
</div>
{tab === "tokens" && <TokensCard pubkey={pubkey} />}
{tab === "history" && <HistoryCard pubkey={pubkey} />}
</>
);
}
function AccountCards({ pubkey }: { pubkey: PublicKey }) {
const fetchAccount = useFetchAccountInfo();
const address = pubkey.toBase58();
@ -56,8 +102,7 @@ function AccountCards({ pubkey }: { pubkey: PublicKey }) {
// Fetch account on load
React.useEffect(() => {
if (pubkey && !info && status === ClusterStatus.Connected)
fetchAccount(pubkey);
if (!info && status === ClusterStatus.Connected) fetchAccount(pubkey);
}, [address, status]); // eslint-disable-line react-hooks/exhaustive-deps
if (!info || info.status === FetchStatus.Fetching) {
@ -137,6 +182,11 @@ function TokensCard({ pubkey }: { pubkey: PublicKey }) {
const fetchAccountTokens = useFetchAccountOwnedTokens();
const refresh = () => fetchAccountTokens(pubkey);
// Fetch owned tokens
React.useEffect(() => {
if (!ownedTokens) refresh();
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
if (ownedTokens === undefined) {
return null;
}
@ -186,7 +236,7 @@ function TokensCard({ pubkey }: { pubkey: PublicKey }) {
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Tokens</h3>
<h3 className="card-header-title">Owned Tokens</h3>
<button
className="btn btn-white btn-sm"
disabled={fetching}
@ -229,6 +279,10 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
const refresh = () => fetchAccountHistory(pubkey, true);
const loadMore = () => fetchAccountHistory(pubkey);
React.useEffect(() => {
if (!history) refresh();
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
if (!info || !history || info.lamports === undefined) {
return null;
} else if (history.fetched === undefined) {

View File

@ -35,7 +35,7 @@ export default function Address({ pubkey, alignRight, link }: Props) {
<span className="c-pointer font-size-tiny mr-2">{copyIcon}</span>
<span className="text-monospace">
{link ? (
<Link className="" to={clusterPath(`/accounts/${address}`)}>
<Link className="" to={clusterPath(`/address/${address}`)}>
{displayAddress(address)}
<span className="fe fe-external-link ml-2"></span>
</Link>

View File

@ -5,7 +5,7 @@ import {
TransactionSignature,
Connection,
} from "@solana/web3.js";
import { useAccounts, FetchStatus } from "./index";
import { FetchStatus } from "./index";
import { useCluster } from "../cluster";
interface AccountHistory {
@ -14,16 +14,19 @@ interface AccountHistory {
foundOldest: boolean;
}
type State = { [address: string]: AccountHistory };
type State = {
url: string;
map: { [address: string]: AccountHistory };
};
export enum ActionType {
Update,
Add,
Clear,
}
interface Update {
type: ActionType.Update;
url: string;
pubkey: PublicKey;
status: FetchStatus;
fetched?: ConfirmedSignatureInfo[];
@ -31,16 +34,12 @@ interface Update {
foundOldest?: boolean;
}
interface Add {
type: ActionType.Add;
address: string;
}
interface Clear {
type: ActionType.Clear;
url: string;
}
type Action = Update | Add | Clear;
type Action = Update | Clear;
type Dispatch = (action: Action) => void;
function combineFetched(
@ -63,42 +62,44 @@ function combineFetched(
function reducer(state: State, action: Action): State {
switch (action.type) {
case ActionType.Add: {
const details = { ...state };
const address = action.address;
if (!details[address]) {
details[address] = {
status: FetchStatus.Fetching,
foundOldest: false,
};
}
return details;
}
case ActionType.Update: {
if (action.url !== state.url) return state;
const address = action.pubkey.toBase58();
if (state[address]) {
if (state.map[address]) {
return {
...state,
[address]: {
status: action.status,
fetched: combineFetched(
action.fetched,
state[address].fetched,
action.before
),
foundOldest: action.foundOldest || state[address].foundOldest,
map: {
...state.map,
[address]: {
status: action.status,
fetched: combineFetched(
action.fetched,
state.map[address].fetched,
action.before
),
foundOldest: action.foundOldest || state.map[address].foundOldest,
},
},
};
} else {
return {
...state,
map: {
...state.map,
[address]: {
status: action.status,
fetched: action.fetched,
foundOldest: action.foundOldest || false,
},
},
};
}
break;
}
case ActionType.Clear: {
return {};
return { url: action.url, map: {} };
}
}
return state;
}
const StateContext = React.createContext<State | undefined>(undefined);
@ -106,30 +107,13 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type HistoryProviderProps = { children: React.ReactNode };
export function HistoryProvider({ children }: HistoryProviderProps) {
const [state, dispatch] = React.useReducer(reducer, {});
const { accounts, lastFetchedAddress } = useAccounts();
const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { url, map: {} });
React.useEffect(() => {
dispatch({ type: ActionType.Clear });
dispatch({ type: ActionType.Clear, url });
}, [url]);
// Fetch history for new accounts
React.useEffect(() => {
if (lastFetchedAddress) {
const infoFetched =
accounts[lastFetchedAddress] &&
accounts[lastFetchedAddress].lamports !== undefined;
const noHistory = !state[lastFetchedAddress];
if (infoFetched && noHistory) {
dispatch({ type: ActionType.Add, address: lastFetchedAddress });
fetchAccountHistory(dispatch, new PublicKey(lastFetchedAddress), url, {
limit: 10,
});
}
}
}, [accounts, lastFetchedAddress]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
@ -149,6 +133,7 @@ async function fetchAccountHistory(
type: ActionType.Update,
status: FetchStatus.Fetching,
pubkey,
url,
});
let status;
@ -168,6 +153,7 @@ async function fetchAccountHistory(
}
dispatch({
type: ActionType.Update,
url,
status,
fetched,
before: options?.before,
@ -183,7 +169,7 @@ export function useAccountHistory(address: string) {
throw new Error(`useAccountHistory must be used within a AccountsProvider`);
}
return context[address];
return context.map[address];
}
export function useFetchAccountHistory() {
@ -197,7 +183,7 @@ export function useFetchAccountHistory() {
}
return (pubkey: PublicKey, refresh?: boolean) => {
const before = state[pubkey.toBase58()];
const before = state.map[pubkey.toBase58()];
if (!refresh && before && before.fetched && before.fetched.length > 0) {
const oldest = before.fetched[before.fetched.length - 1].signature;
fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 });

View File

@ -1,7 +1,7 @@
import React from "react";
import { StakeAccount } from "solana-sdk-wasm";
import { PublicKey, Connection, StakeProgram } from "@solana/web3.js";
import { useCluster, ClusterStatus } from "../cluster";
import { useCluster } from "../cluster";
import { HistoryProvider } from "./history";
import { TokensProvider } from "./tokens";
export { useAccountHistory } from "./history";
@ -29,7 +29,6 @@ export interface Account {
type Accounts = { [address: string]: Account };
interface State {
accounts: Accounts;
lastFetchedAddress: string | undefined;
}
export enum ActionType {
@ -73,7 +72,7 @@ function reducer(state: State, action: Action): State {
status: FetchStatus.Fetching,
},
};
return { ...state, accounts, lastFetchedAddress: address };
return { ...state, accounts };
} else {
const accounts = {
...state.accounts,
@ -82,7 +81,7 @@ function reducer(state: State, action: Action): State {
pubkey: action.pubkey,
},
};
return { ...state, accounts, lastFetchedAddress: address };
return { ...state, accounts };
}
}
@ -112,9 +111,6 @@ function reducer(state: State, action: Action): State {
return state;
}
export const ACCOUNT_ALIASES = ["account", "address"];
export const ACCOUNT_ALIASES_PLURAL = ["accounts", "addresses"];
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@ -122,18 +118,13 @@ type AccountsProviderProps = { children: React.ReactNode };
export function AccountsProvider({ children }: AccountsProviderProps) {
const [state, dispatch] = React.useReducer(reducer, {
accounts: {},
lastFetchedAddress: undefined,
});
// Check account statuses on startup and whenever cluster updates
const { status, url } = useCluster();
// Clear account statuses whenever cluster is changed
const { url } = useCluster();
React.useEffect(() => {
if (status === ClusterStatus.Connecting) {
dispatch({ type: ActionType.Clear });
} else if (status === ClusterStatus.Connected && state.lastFetchedAddress) {
fetchAccountInfo(dispatch, new PublicKey(state.lastFetchedAddress), url);
}
}, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps
dispatch({ type: ActionType.Clear });
}, [url]);
return (
<StateContext.Provider value={state}>

View File

@ -1,7 +1,7 @@
import React from "react";
import { Connection, PublicKey } from "@solana/web3.js";
import { FetchStatus, useAccounts } from "./index";
import { useCluster, Cluster } from "../cluster";
import { FetchStatus } from "./index";
import { useCluster } from "../cluster";
import { number, string, boolean, coerce, object, nullable } from "superstruct";
export type TokenAccountData = {
@ -28,22 +28,38 @@ interface AccountTokens {
}
interface Update {
type: "update";
url: string;
pubkey: PublicKey;
status: FetchStatus;
tokens?: TokenAccountData[];
}
type Action = Update | "clear";
type State = { [address: string]: AccountTokens };
interface Clear {
type: "clear";
url: string;
}
type Action = Update | Clear;
type State = {
url: string;
map: { [address: string]: AccountTokens };
};
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
if (action === "clear") {
return {};
if (action.type === "clear") {
return {
url: action.url,
map: {},
};
} else if (action.url !== state.url) {
return state;
}
const address = action.pubkey.toBase58();
let addressEntry = state[address];
let addressEntry = state.map[address];
if (addressEntry && action.status === FetchStatus.Fetching) {
addressEntry = {
...addressEntry,
@ -58,7 +74,10 @@ function reducer(state: State, action: Action): State {
return {
...state,
[address]: addressEntry,
map: {
...state.map,
[address]: addressEntry,
},
};
}
@ -67,27 +86,13 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type ProviderProps = { children: React.ReactNode };
export function TokensProvider({ children }: ProviderProps) {
const [state, dispatch] = React.useReducer(reducer, {});
const { cluster, url } = useCluster();
const { accounts, lastFetchedAddress } = useAccounts();
const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { url, map: {} });
React.useEffect(() => {
dispatch("clear");
dispatch({ url, type: "clear" });
}, [url]);
// Fetch history for new accounts
React.useEffect(() => {
if (lastFetchedAddress && cluster !== Cluster.MainnetBeta) {
const infoFetched =
accounts[lastFetchedAddress] &&
accounts[lastFetchedAddress].lamports !== undefined;
const noRecord = !state[lastFetchedAddress];
if (infoFetched && noRecord) {
fetchAccountTokens(dispatch, new PublicKey(lastFetchedAddress), url);
}
}
}, [accounts, lastFetchedAddress]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
@ -107,8 +112,10 @@ async function fetchAccountTokens(
url: string
) {
dispatch({
type: "update",
status: FetchStatus.Fetching,
pubkey,
url,
});
let status;
@ -133,7 +140,7 @@ async function fetchAccountTokens(
} catch (error) {
status = FetchStatus.FetchFailed;
}
dispatch({ status, tokens, pubkey });
dispatch({ type: "update", url, status, tokens, pubkey });
}
export function useAccountOwnedTokens(address: string) {
@ -145,7 +152,7 @@ export function useAccountOwnedTokens(address: string) {
);
}
return context[address];
return context.map[address];
}
export function useFetchAccountOwnedTokens() {