feat: add rpc-calls page

This commit is contained in:
Victor Oliva
2025-06-27 15:29:37 +02:00
parent 8c311e35cd
commit 5d916e8c75
8 changed files with 318 additions and 7 deletions

View File

@@ -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"
@@ -20,6 +21,7 @@ export default function App() {
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./RpcCalls"

View File

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