Split explorer account details page into tabs (#11450)
This commit is contained in:
parent
bad486823c
commit
67fdf593a2
|
@ -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="/">
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in New Issue