Merge pull request #103 from polkadot-api/vo/view-fns

feat: add view functions
This commit is contained in:
Victor Oliva
2025-11-15 10:03:28 -03:00
committed by GitHub
8 changed files with 528 additions and 13 deletions
+3 -1
View File
@@ -1,3 +1,4 @@
import { VaultTxModal } from "polkahub"
import { Navigate, Route, Routes } from "react-router-dom"
import { Accounts } from "./pages/Accounts/Accounts"
import { Constants } from "./pages/Constants"
@@ -9,7 +10,7 @@ import { RpcCalls } from "./pages/RpcCalls"
import { RuntimeCalls } from "./pages/RuntimeCalls"
import { Storage } from "./pages/Storage"
import { Transactions } from "./pages/Transactions"
import { VaultTxModal } from "polkahub"
import { ViewFns } from "./pages/ViewFns"
export default function App() {
return (
@@ -26,6 +27,7 @@ export default function App() {
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="metadata/*" element={<Metadata />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="viewFns/*" element={<ViewFns />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</div>
+3
View File
@@ -82,6 +82,9 @@ export const Header = () => {
</NavLink>
))}
</nav>
<NavLink to="/viewFns" onClick={() => setIsOpen(false)}>
View Functions
</NavLink>
<NavLink to="/metadata" onClick={() => setIsOpen(false)}>
Metadata
</NavLink>
+49 -12
View File
@@ -1,8 +1,8 @@
import { CopyText } from "@/components/Copy"
import { SearchableSelect } from "@/components/Select"
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { FC, useEffect, useState } from "react"
import { FC, Fragment, useState } from "react"
import { LookupLink } from "./Lookup"
import { CopyText } from "@/components/Copy"
type Pallet = UnifiedMetadata["pallets"][number]
export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
@@ -22,7 +22,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
/>
</label>
{pallet && (
<>
<Fragment key={pallet.name}>
<p>Index: {pallet.index}</p>
{pallet.storage && (
<div>
@@ -36,6 +36,12 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
<PalletConstants pallet={pallet} />
</div>
)}
{pallet.viewFns.length > 0 && (
<div>
<h4>View Functions</h4>
<PalletViewFns pallet={pallet} />
</div>
)}
{pallet.calls != null && (
<div>
<h4>Calls</h4>
@@ -54,7 +60,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
<LookupLink id={pallet.errors.type} />
</div>
)}
</>
</Fragment>
)}
</div>
)
@@ -68,10 +74,6 @@ type StorageEntry = Pallet["storage"] extends
const PalletStorage: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<StorageEntry | null>(null)
useEffect(() => {
setEntry(null)
}, [pallet])
if (!pallet.storage) return null
const value =
@@ -129,10 +131,6 @@ type ConstantEntry = Pallet["constants"] extends Array<infer R> ? R : never
const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<ConstantEntry | null>(null)
useEffect(() => {
setEntry(null)
}, [pallet])
if (!pallet.constants.length) return null
return (
@@ -166,3 +164,42 @@ const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
</div>
)
}
type ViewFnEntry = Pallet["viewFns"] extends Array<infer R> ? R : never
const PalletViewFns: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<ViewFnEntry | null>(null)
if (!pallet.viewFns.length) return null
return (
<div className="flex flex-col p-2 gap-2">
<SearchableSelect
value={entry}
setValue={setEntry}
options={pallet.viewFns.map((c) => ({
text: c.name,
value: c,
}))}
/>
{entry && (
<>
<div>
<h4>Inputs</h4>
<ol>
{entry.inputs.map((input) => (
<li key={input.name}>
<div>{input.name}</div>
<LookupLink id={input.type} />
</li>
))}
</ol>
</div>
<div>
<h4>Output</h4>
<LookupLink id={entry.output} />
</div>
</>
)}
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
import {
dynamicBuilder$,
lookup$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
InlineLookupTypeEdit,
LookupTypeEdit,
} from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { getTypeComplexity } from "@/utils/shape"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { Circle, Dot } from "lucide-react"
import { FC, useState } from "react"
import {
combineLatest,
filter,
firstValueFrom,
map,
scan,
startWith,
switchMap,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { addViewFnCall, selectedEntry$ } from "./viewFns.state"
export const ViewFnQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, inputValues, builder] = await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
inputValues$,
dynamicBuilder$,
]),
)
const decodedValues = inputValues.map((v, i) =>
builder
.buildDefinition(selectedEntry.inputs[i].type)
.dec(v as Uint8Array),
)
const promise = unsafeApi.view[entry!.pallet][entry!.name](...decodedValues)
addViewFnCall({
name: `${entry!.pallet}.${entry!.name}(…)`,
promise,
type: entry!.output,
})
}
return (
<div className="flex flex-col gap-4 items-start w-full">
<ViewFnInputValues />
<ActionButton disabled={!isReady} onClick={submit}>
Call
</ActionButton>
</div>
)
}
const [inputValueChange$, setInputValue] = createSignal<{
idx: number
value: Uint8Array | "partial" | null
}>()
const inputValues$ = selectedEntry$.pipeState(
filter((v) => !!v),
map((v) => v.inputs),
switchMap((inputs) => {
const values: Array<Uint8Array | "partial" | null> = inputs.map(() => null)
return inputValueChange$.pipe(
scan((acc, change) => {
const newValue = [...acc]
newValue[change.idx] = change.value
return newValue
}, values),
startWith(values),
)
}),
withDefault([] as Array<Uint8Array | "partial" | null>),
)
const isReady$ = inputValues$.pipeState(
map((inputValues) => inputValues.every((v) => v instanceof Uint8Array)),
withDefault(false),
)
const ViewFnInputValues: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
if (!selectedEntry || !selectedEntry.inputs.length) return null
return (
<div className="w-full">
Inputs
<ol className="flex flex-col gap-2">
{selectedEntry.inputs.map((input, idx) => (
<RuntimeValueInput
key={idx}
idx={idx}
name={input.name}
type={input.type}
/>
))}
</ol>
</div>
)
}
const inputValue$ = state(
(idx: number) => inputValues$.pipe(map((v) => v[idx])),
null,
)
const lookupState$ = state(lookup$, null)
const RuntimeValueInput: FC<{ idx: number; name: string; type: number }> = ({
idx,
type,
name,
}) => {
const value = useStateObservable(inputValue$(idx))
const [expanded, setExpanded] = useState(false)
const lookup = useStateObservable(lookupState$)
if (!lookup) return null
const shape = lookup(type)
const complexity = getTypeComplexity(shape)
return (
<li key={idx} className="border rounded p-2 w-full">
<div
className={twMerge(
"flex items-center select-none",
complexity !== "inline" && "cursor-pointer",
)}
onClick={() => setExpanded((e) => !e)}
>
{complexity === "inline" ? (
<Dot size={16} />
) : (
<ExpandBtn expanded={expanded} />
)}
<Circle
size={8}
strokeWidth={4}
className={twMerge(
"mr-1",
value === null
? "text-red-600"
: value === "partial"
? "text-orange-600"
: "text-green-600",
)}
/>
<div className="text-foreground/80">{name}</div>
{complexity === "inline" ? (
<div className="px-2">
<InlineLookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setInputValue({ idx, value })}
/>
</div>
) : null}
</div>
{expanded && complexity !== "inline" && (
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
<LookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setInputValue({ idx, value })}
tree={complexity === "tree"}
/>
</div>
)}
</li>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { ButtonGroup } from "@/components/ButtonGroup"
import { useStateObservable } from "@react-rxjs/core"
import { Trash2 } from "lucide-react"
import { FC, useState } from "react"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import {
removeViewFnResult,
ViewFnResult,
viewFnResult$,
viewFnResultKeys$,
} from "./viewFns.state"
import { PathsRoot } from "@/codec-components/common/paths.state"
export const ViewFnResults: FC = () => {
const keys = useStateObservable(viewFnResultKeys$)
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) => (
<ViewFnResultBox key={key} subscription={key} />
))}
</ul>
</div>
)
}
const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
const [mode, setMode] = useState<"json" | "decoded">("decoded")
const viewFnResult = useStateObservable(viewFnResult$(subscription))
if (!viewFnResult) 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">
{viewFnResult.name}
</h3>
<div className="flex items-center shrink-0 gap-2">
<ButtonGroup
value={mode}
onValueChange={setMode as any}
items={[
{
value: "decoded",
content: "Decoded",
},
{
value: "json",
content: "JSON",
},
]}
/>
<button onClick={() => removeViewFnResult(subscription)}>
<Trash2
size={20}
className="text-destructive cursor-pointer hover:text-polkadot-500"
/>
</button>
</div>
</div>
<PathsRoot.Provider value={subscription}>
<ResultDisplay viewFnResult={viewFnResult} mode={mode} />
</PathsRoot.Provider>
</li>
)
}
const ResultDisplay: FC<{
viewFnResult: ViewFnResult
mode: "json" | "decoded"
}> = ({ viewFnResult, mode }) => {
if ("error" in viewFnResult) {
return (
<div className="text-sm">
<div>The call crashed</div>
<div>Message: {viewFnResult.error.message ?? "N/A"}</div>
</div>
)
}
if (!("result" in viewFnResult)) {
return <div className="text-sm text-foreground/50">Loading…</div>
}
return (
<div className="max-h-[60svh] overflow-auto">
<ValueDisplay
mode={mode}
type={viewFnResult.type}
value={viewFnResult.result}
title={"Result"}
/>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { lookup$ } from "@/state/chains/chain.state"
import { DocsRenderer } from "@/components/DocsRenderer"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { state, useStateObservable } from "@react-rxjs/core"
import { useEffect, useState } from "react"
import { map } from "rxjs"
import { ViewFnResults } from "./ViewFnResults"
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
import { ViewFnQuery } from "./ViewFnQuery"
const metadataViewFns$ = state(
lookup$.pipe(
map((lookup) => ({
lookup,
entries: Object.fromEntries(
lookup.metadata.pallets
.filter((p) => p.viewFns.length)
.map((p) => [
p.name,
Object.fromEntries(
p.viewFns.map((method) => [method.name, method]),
),
]),
),
})),
),
)
export const ViewFns = withSubscribe(
() => {
const { lookup, entries } = useStateObservable(metadataViewFns$)
const defaultPallet = Object.keys(entries)[0] ?? null
const defaultFn = defaultPallet
? Object.keys(entries[defaultPallet])[0]
: null
const [pallet, setPallet] = useState<string | null>(defaultPallet)
const [fnName, setFnName] = useState<string | null>(defaultFn)
const entry = useStateObservable(selectedEntry$)
const selectedPallet =
(pallet && lookup.metadata.pallets.find((p) => p.name === pallet)) || null
useEffect(
() =>
setFnName((prev) => {
if (!selectedPallet?.viewFns[0]) return null
return selectedPallet.viewFns.some((v) => v.name === prev)
? prev
: selectedPallet.viewFns[0].name
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedPallet?.name],
)
useEffect(() => {
const selectedMethod =
(fnName && selectedPallet?.viewFns.find((it) => it.name === fnName)) ||
null
setSelectedFn(
selectedMethod
? { ...selectedMethod, pallet: selectedPallet!.name }
: null,
)
}, [selectedPallet, fnName])
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-2">
<label>
Pallet
<SearchableSelect
value={pallet}
setValue={(v) => setPallet(v)}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{selectedPallet && pallet && (
<label>
Function
<SearchableSelect
value={fnName}
setValue={(v) => setFnName(v)}
options={
Object.keys(entries[pallet]).map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
{!!entry?.docs.length && (
<div className="w-full">
Docs
<DocsRenderer docs={entry.docs} />
</div>
)}
<ViewFnQuery />
<ViewFnResults />
</div>
)
},
{
fallback: <LoadingMetadata />,
},
)
+1
View File
@@ -0,0 +1 @@
export * from "./ViewFns"
+79
View File
@@ -0,0 +1,79 @@
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
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"
type Pallet = UnifiedMetadata["pallets"][number]
export type ViewFnEntry = Pallet["viewFns"][number] & {
pallet: string
}
export const [entryChange$, setSelectedFn] = createSignal<ViewFnEntry | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [newViewFnCall$, addViewFnCall] = createSignal<{
name: string
type: number
promise: Promise<unknown>
}>()
export const [removeViewFnResult$, removeViewFnResult] =
createKeyedSignal<string>()
export type ViewFnResult = {
name: string
type: number
} & ({ result: unknown } | { error?: any })
const [getViewFnSubscription$, viewFnKeyChange$] = partitionByKey(
newViewFnCall$,
() => uuid(),
(src$, id) =>
src$.pipe(
switchMap(
({ promise, ...props }): Observable<ViewFnResult> =>
from(promise).pipe(
map((result) => ({
...props,
result,
paused: false,
})),
catchError((ex) => {
console.error(ex)
return of({
...props,
error: ex,
})
}),
startWith(props),
),
),
takeUntil(removeViewFnResult$(id)),
),
)
export const viewFnResultKeys$ = state(
viewFnKeyChange$.pipe(
toKeySet(),
map((keys) => [...keys].reverse()),
),
[],
)
export const viewFnResult$ = state(
(key: string): Observable<ViewFnResult> => getViewFnSubscription$(key),
null,
)