Merge pull request #67 from polkadot-api/vo/rpc-calls
feat: add rpc-calls page
This commit is contained in:
+3
-1
@@ -4,6 +4,7 @@ import { Explorer } from "./pages/Explorer"
|
||||
import { Extrinsics } from "./pages/Extrinsics"
|
||||
import { Header } from "./pages/Header"
|
||||
import { Metadata } from "./pages/Metadata"
|
||||
import { RpcCalls } from "./pages/RpcCalls"
|
||||
import { RuntimeCalls } from "./pages/RuntimeCalls"
|
||||
import { Storage } from "./pages/Storage"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
@@ -13,13 +14,14 @@ export default function App() {
|
||||
<div className="w-full h-screen bg-background flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
<div className="max-w-(--breakpoint-lg) m-auto">
|
||||
<div className="max-w-(--breakpoint-xl) m-auto">
|
||||
<Routes>
|
||||
<Route path="explorer/*" element={<Explorer />} />
|
||||
<Route path="extrinsics/*" element={<Extrinsics />} />
|
||||
<Route path="storage/*" element={<Storage />} />
|
||||
<Route path="constants/*" element={<Constants />} />
|
||||
<Route path="runtimeCalls/*" element={<RuntimeCalls />} />
|
||||
<Route path="rpcCalls/*" element={<RpcCalls />} />
|
||||
<Route path="metadata/*" element={<Metadata />} />
|
||||
<Route path="*" element={<Navigate to="/explorer" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -10,17 +10,26 @@ import {
|
||||
CommandList,
|
||||
} from "./ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export const SearchableSelect = <T,>({
|
||||
value,
|
||||
setValue,
|
||||
options,
|
||||
className,
|
||||
contentClassName,
|
||||
allowCustomValue,
|
||||
}: {
|
||||
value: T | null
|
||||
setValue: (val: T | null) => void
|
||||
options: { value: T; text: string }[]
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
// only for T=string
|
||||
allowCustomValue?: boolean
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filterValue, setFilterValue] = useState("")
|
||||
|
||||
const onTriggerKeyDown = (evt: React.KeyboardEvent) => {
|
||||
if (evt.key.length === 1) {
|
||||
@@ -28,6 +37,38 @@ export const SearchableSelect = <T,>({
|
||||
}
|
||||
}
|
||||
|
||||
const lowerCaseFilter = filterValue.toLocaleLowerCase()
|
||||
const hasExactMatch = options.some((v) => v.value === filterValue)
|
||||
const filteredOptions = allowCustomValue
|
||||
? [
|
||||
...options
|
||||
.filter(({ text }) =>
|
||||
text.toLocaleLowerCase().includes(lowerCaseFilter),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aLowerCase = a.text.toLocaleLowerCase()
|
||||
if (aLowerCase === lowerCaseFilter) return -1
|
||||
const bLowerCase = b.text.toLocaleLowerCase()
|
||||
if (bLowerCase === lowerCaseFilter) return 1
|
||||
|
||||
const aScore = aLowerCase.startsWith(lowerCaseFilter) ? 1 : 0
|
||||
const bScore = bLowerCase.startsWith(lowerCaseFilter) ? 1 : 0
|
||||
if (aScore != bScore) {
|
||||
return bScore - aScore
|
||||
}
|
||||
return aLowerCase.localeCompare(bLowerCase)
|
||||
}),
|
||||
...(hasExactMatch || !filterValue
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: filterValue as T,
|
||||
text: filterValue,
|
||||
},
|
||||
]),
|
||||
]
|
||||
: options
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onKeyDown={onTriggerKeyDown}>
|
||||
@@ -35,11 +76,15 @@ export const SearchableSelect = <T,>({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex w-52 justify-between overflow-hidden bg-input border border-border"
|
||||
className={twMerge(
|
||||
"flex w-52 justify-between overflow-hidden bg-input border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value ? (
|
||||
<span className="text-ellipsis overflow-hidden">
|
||||
{options.find((option) => option.value === value)?.text}
|
||||
{options.find((option) => option.value === value)?.text ??
|
||||
(allowCustomValue ? filterValue : null)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-80">Select…</span>
|
||||
@@ -47,12 +92,12 @@ export const SearchableSelect = <T,>({
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Filter…" />
|
||||
<PopoverContent className={twMerge("w-[200px] p-0", contentClassName)}>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Filter…" onValueChange={setFilterValue} />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{options.map((option, i) => (
|
||||
{filteredOptions.map((option, i) => (
|
||||
<CommandItem
|
||||
key={i}
|
||||
value={option.text}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-white data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-neutral-950",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-white focus:ring-neutral-950 data-[state=open]:bg-neutral-100 absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-neutral-950 font-semibold dark:text-neutral-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-neutral-500 text-sm dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-neutral-200 placeholder:text-neutral-500 focus-visible:border-neutral-950 focus-visible:ring-neutral-950/50 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 dark:bg-neutral-200/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:placeholder:text-neutral-400 dark:focus-visible:border-neutral-300 dark:focus-visible:ring-neutral-300/50 dark:aria-invalid:ring-red-900/20 dark:dark:aria-invalid:ring-red-900/40 dark:aria-invalid:border-red-900 dark:dark:bg-neutral-800/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -13,7 +13,7 @@ export const Explorer = withSubscribe(
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="p-4 pb-0">
|
||||
<div className="p-4 pb-0 max-w-(--breakpoint-lg) m-auto">
|
||||
<Summary />
|
||||
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
|
||||
<BlockTable />
|
||||
|
||||
@@ -71,7 +71,7 @@ export const Extrinsics = withSubscribe(
|
||||
className={twMerge(
|
||||
"flex flex-col overflow-hidden gap-2 p-4 pb-0",
|
||||
// Bypassing top-level scroll area, since we need a specific scroll area for the tree view
|
||||
"absolute w-full h-full max-w-(--breakpoint-lg)",
|
||||
"absolute w-full h-full max-w-(--breakpoint-xl)",
|
||||
)}
|
||||
>
|
||||
<BinaryDisplay
|
||||
|
||||
+89
-32
@@ -1,44 +1,99 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Link } from "@/hashParams"
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
import { Menu } from "lucide-react"
|
||||
import { FC, PropsWithChildren, useState } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { NetworkSwitcher } from "./Network/Network"
|
||||
|
||||
export const Header = () => (
|
||||
<div className="shrink-0 border-b">
|
||||
<div className="flex p-4 pb-2 items-center gap-2 max-w-(--breakpoint-lg) m-auto">
|
||||
<div className="flex flex-1 items-center flex-row gap-2 relative">
|
||||
<img
|
||||
className="w-14 min-w-14 hidden dark:inline-block"
|
||||
src="/papi_logo-dark.svg"
|
||||
alt="papi-logo"
|
||||
/>
|
||||
<img
|
||||
className="w-14 min-w-14 dark:hidden"
|
||||
src="/papi_logo-light.svg"
|
||||
alt="papi-logo"
|
||||
/>
|
||||
<h1 className="hidden lg:block poppins-regular text-lg">
|
||||
papi <span className="poppins-extralight">console</span>
|
||||
</h1>
|
||||
<div className="absolute -bottom-1 left-0 lg:bottom-0 lg:right-1 text-right text-sm">
|
||||
(beta)
|
||||
const navigationItems = [
|
||||
{ path: "/explorer", label: "Explorer", important: true },
|
||||
{ path: "/storage", label: "Storage", important: true },
|
||||
{ path: "/extrinsics", label: "Extrinsics", important: true },
|
||||
{ path: "/constants", label: "Constants", important: false },
|
||||
{ path: "/runtimeCalls", label: "Runtime Calls", important: true },
|
||||
{ path: "/rpcCalls", label: "RPC Calls", important: false },
|
||||
{ path: "/metadata", label: "Metadata", important: false },
|
||||
]
|
||||
|
||||
export const Header = () => {
|
||||
const [open, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b">
|
||||
<div className="flex p-4 pb-2 items-center gap-2 max-w-(--breakpoint-xl) m-auto">
|
||||
<div className="flex items-center flex-row gap-2 relative mr-2">
|
||||
<img
|
||||
className="w-14 min-w-14 hidden dark:inline-block"
|
||||
src="/papi_logo-dark.svg"
|
||||
alt="papi-logo"
|
||||
/>
|
||||
<img
|
||||
className="w-14 min-w-14 dark:hidden"
|
||||
src="/papi_logo-light.svg"
|
||||
alt="papi-logo"
|
||||
/>
|
||||
<h1 className="hidden min-[72rem]:block poppins-regular text-lg">
|
||||
papi <span className="poppins-extralight">console</span>
|
||||
</h1>
|
||||
<div className="absolute -bottom-1 left-0 min-[72rem]:bottom-0 min-[72rem]:right-1 text-right text-sm">
|
||||
(beta)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NetworkSwitcher />
|
||||
<div className="flex flex-row items-center justify-end px-1 py-1 text-nowrap">
|
||||
<NavLink to="/explorer">Explorer</NavLink>
|
||||
<NavLink to="/storage">Storage</NavLink>
|
||||
<NavLink to="/extrinsics">Extrinsics</NavLink>
|
||||
<NavLink to="/constants">Constants</NavLink>
|
||||
<NavLink to="/runtimeCalls">Runtime Calls</NavLink>
|
||||
<NavLink to="/metadata">Metadata</NavLink>
|
||||
<NetworkSwitcher />
|
||||
<div className="flex-1" />
|
||||
<nav className="flex flex-row items-center justify-end px-1 py-1 text-nowrap flex-wrap min-w-56">
|
||||
{navigationItems.map(({ path, label, important }) => (
|
||||
<NavLink
|
||||
to={path}
|
||||
key={path}
|
||||
className={
|
||||
important ? "text-sm sm:text-base" : "hidden lg:inline-block"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Sheet open={open} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-foreground hover:bg-accent"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="bg-background border-accent w-64 pt-10"
|
||||
>
|
||||
<NetworkSwitcher forSmallScreen />
|
||||
<nav className="flex flex-col gap-4">
|
||||
{navigationItems
|
||||
.filter(({ important }) => !important)
|
||||
.map(({ path, label }) => (
|
||||
<NavLink
|
||||
to={path}
|
||||
key={path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const NavLink: FC<PropsWithChildren<{ to: string }>> = ({ to, children }) => {
|
||||
const NavLink: FC<
|
||||
PropsWithChildren<{ to: string; onClick?: () => void; className?: string }>
|
||||
> = ({ to, children, className, onClick }) => {
|
||||
const location = useLocation()
|
||||
const active = location.pathname.startsWith(to)
|
||||
|
||||
@@ -49,7 +104,9 @@ const NavLink: FC<PropsWithChildren<{ to: string }>> = ({ to, children }) => {
|
||||
"transition-colors text-foreground/75 hover:text-foreground cursor-pointer px-3 py-1 rounded",
|
||||
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active && "text-foreground font-bold",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -32,8 +32,13 @@ import { addCustomNetwork, getCustomNetwork } from "@/state/chains/networks"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { FC, useState } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function NetworkSwitcher() {
|
||||
export function NetworkSwitcher({
|
||||
forSmallScreen,
|
||||
}: {
|
||||
forSmallScreen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedChain = useStateObservable(selectedChain$)
|
||||
|
||||
@@ -42,7 +47,10 @@ export function NetworkSwitcher() {
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[200px] flex gap-0 justify-between text-base px-3 border border-border bg-input"
|
||||
className={twMerge(
|
||||
"w-[200px] gap-0 justify-between text-base px-3 border border-border bg-input self-center",
|
||||
forSmallScreen ? "flex md:hidden" : "hidden md:flex",
|
||||
)}
|
||||
>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{selectedChain.network.display}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PathsRoot } from "@/codec-components/common/paths.state"
|
||||
import { JsonDisplay } from "@/components/JsonDisplay"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { FC } from "react"
|
||||
import {
|
||||
removeRpcCallResult,
|
||||
RpcCallResult,
|
||||
rpcCallResult$,
|
||||
rpcCallResultKeys$,
|
||||
} from "./rpcCalls.state"
|
||||
|
||||
export const RpcCallResults: FC = () => {
|
||||
const keys = useStateObservable(rpcCallResultKeys$)
|
||||
|
||||
if (!keys.length) return null
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full border-t border-border">
|
||||
<h2 className="text-lg text-foreground mb-2">Results</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{keys.map((key) => (
|
||||
<RpcCallResultBox key={key} subscription={key} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RpcCallResultBox: FC<{ subscription: string }> = ({ subscription }) => {
|
||||
const rpcCallResult = useStateObservable(rpcCallResult$(subscription))
|
||||
if (!rpcCallResult) return null
|
||||
|
||||
return (
|
||||
<li className="border rounded bg-card text-card-foreground p-2">
|
||||
<div className="flex justify-between items-center pb-1 overflow-hidden">
|
||||
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-sm">
|
||||
{rpcCallResult.method}({rpcCallResult.payload})
|
||||
</h3>
|
||||
<div className="flex items-center shrink-0 gap-2">
|
||||
<button onClick={() => removeRpcCallResult(subscription)}>
|
||||
<Trash2
|
||||
size={20}
|
||||
className="text-destructive cursor-pointer hover:text-polkadot-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PathsRoot.Provider value={subscription}>
|
||||
<ResultDisplay rpcCallResult={rpcCallResult} />
|
||||
</PathsRoot.Provider>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultDisplay: FC<{
|
||||
rpcCallResult: RpcCallResult
|
||||
}> = ({ rpcCallResult }) => {
|
||||
if ("error" in rpcCallResult) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>The call crashed</div>
|
||||
<div>Message: {rpcCallResult.error.message ?? "N/A"}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!("result" in rpcCallResult)) {
|
||||
return <div className="text-sm text-foreground/50">Loading…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[60svh] overflow-auto">
|
||||
<JsonDisplay src={rpcCallResult.result} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { LoadingMetadata } from "@/components/Loading"
|
||||
import { SearchableSelect } from "@/components/Select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { withSubscribe } from "@/components/withSuspense"
|
||||
import { chainClient$ } from "@/state/chains/chain.state"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { useState } from "react"
|
||||
import { firstValueFrom, map, switchMap } from "rxjs"
|
||||
import { RpcCallResults } from "./RpcCallResults"
|
||||
import { addRpcCallQuery } from "./rpcCalls.state"
|
||||
|
||||
const chainRpcMethods$ = state(
|
||||
chainClient$.pipe(
|
||||
switchMap((chain) =>
|
||||
chain.client._request<{ methods: string[] }>("rpc_methods", []),
|
||||
),
|
||||
map((r) => r.methods),
|
||||
),
|
||||
)
|
||||
|
||||
export const RpcCalls = withSubscribe(
|
||||
() => {
|
||||
const methods = useStateObservable(chainRpcMethods$)
|
||||
const [method, setMethod] = useState<string | null>(null)
|
||||
const [params, setParams] = useState<string>("[\n\n]")
|
||||
|
||||
const submit = async () => {
|
||||
const { client } = await firstValueFrom(chainClient$)
|
||||
|
||||
const promise = client._request(method!, JSON.parse(params))
|
||||
addRpcCallQuery({
|
||||
method: method!,
|
||||
payload: JSON.stringify(JSON.parse(params)),
|
||||
promise,
|
||||
})
|
||||
}
|
||||
|
||||
const isReady = method !== "" && isJson(params)
|
||||
|
||||
return (
|
||||
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
|
||||
<label className="w-full">
|
||||
RPC
|
||||
<SearchableSelect
|
||||
className="w-md max-w-full"
|
||||
contentClassName="w-md max-w-full"
|
||||
value={method}
|
||||
setValue={(v) => setMethod(v)}
|
||||
options={methods.map((e) => ({
|
||||
text: e,
|
||||
value: e,
|
||||
}))}
|
||||
allowCustomValue
|
||||
/>
|
||||
</label>
|
||||
<label className="w-full">
|
||||
JSON Payload
|
||||
<Textarea
|
||||
className="w-full font-mono text-sm"
|
||||
value={params}
|
||||
onChange={(e) => setParams(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<ActionButton disabled={!isReady} onClick={submit}>
|
||||
Call
|
||||
</ActionButton>
|
||||
<RpcCallResults />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
fallback: <LoadingMetadata />,
|
||||
},
|
||||
)
|
||||
|
||||
const isJson = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./RpcCalls"
|
||||
@@ -0,0 +1,84 @@
|
||||
import { state } from "@react-rxjs/core"
|
||||
import {
|
||||
createKeyedSignal,
|
||||
createSignal,
|
||||
partitionByKey,
|
||||
toKeySet,
|
||||
} from "@react-rxjs/utils"
|
||||
import {
|
||||
catchError,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs"
|
||||
import { v4 as uuid } from "uuid"
|
||||
|
||||
export type RpcCallMetadataMethod = {
|
||||
api: string
|
||||
name: string
|
||||
inputs: {
|
||||
name: string
|
||||
type: number
|
||||
}[]
|
||||
output: number
|
||||
docs: string[]
|
||||
}
|
||||
|
||||
export const [entryChange$, setSelectedMethod] =
|
||||
createSignal<RpcCallMetadataMethod | null>()
|
||||
export const selectedEntry$ = state(entryChange$, null)
|
||||
|
||||
export const [newRpcCallQuery$, addRpcCallQuery] = createSignal<{
|
||||
method: string
|
||||
payload: string
|
||||
promise: Promise<unknown>
|
||||
}>()
|
||||
export const [removeRpcCallResult$, removeRpcCallResult] =
|
||||
createKeyedSignal<string>()
|
||||
|
||||
export type RpcCallResult = {
|
||||
method: string
|
||||
payload: string
|
||||
} & ({ result: unknown } | { error?: any })
|
||||
const [getRpcCallSubscription$, rpcCallKeyChange$] = partitionByKey(
|
||||
newRpcCallQuery$,
|
||||
() => uuid(),
|
||||
(src$, id) =>
|
||||
src$.pipe(
|
||||
switchMap(
|
||||
({ promise, ...props }): Observable<RpcCallResult> =>
|
||||
from(promise).pipe(
|
||||
map((result) => ({
|
||||
...props,
|
||||
result,
|
||||
})),
|
||||
catchError((ex) => {
|
||||
console.error(ex)
|
||||
return of({
|
||||
...props,
|
||||
error: ex,
|
||||
})
|
||||
}),
|
||||
startWith(props),
|
||||
),
|
||||
),
|
||||
takeUntil(removeRpcCallResult$(id)),
|
||||
),
|
||||
)
|
||||
|
||||
export const rpcCallResultKeys$ = state(
|
||||
rpcCallKeyChange$.pipe(
|
||||
toKeySet(),
|
||||
map((keys) => [...keys].reverse()),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export const rpcCallResult$ = state(
|
||||
(key: string): Observable<RpcCallResult> => getRpcCallSubscription$(key),
|
||||
null,
|
||||
)
|
||||
Reference in New Issue
Block a user