feat: viewFn query at block, populate args with selected query (#149)

This commit is contained in:
Victor Oliva
2026-05-29 13:49:47 +02:00
committed by GitHub
parent 30a839a54a
commit 547c1e0129
5 changed files with 155 additions and 124 deletions
+30 -7
View File
@@ -13,6 +13,7 @@ import { Circle, Dot } from "lucide-react"
import { FC, useState } from "react"
import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
@@ -21,10 +22,11 @@ import {
switchMap,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { addViewFnCall, selectedEntry$ } from "./viewFns.state"
import { selectedBlock$ } from "../Storage/BlockPicker"
import { addViewFnCall, viewFnEntryState } from "./viewFns.state"
export const ViewFnQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const selectedEntry = useStateObservable(viewFnEntryState.selectedEntry$)
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
@@ -33,10 +35,27 @@ export const ViewFnQuery: FC = () => {
const submit = async () => {
const [entry, inputValues, builder, block] = await firstValueFrom(
combineLatest([
selectedEntry$,
viewFnEntryState.selectedEntry$,
inputValues$,
dynamicBuilder$,
client$.pipe(switchMap((client) => client.finalizedBlock$)),
selectedBlock$.pipe(
switchMap((block) =>
block.hash
? [
{
latest: false,
hash: block.hash,
},
]
: client$.pipe(
switchMap((client) => client.finalizedBlock$),
map((v) => ({
latest: true,
hash: v.hash,
})),
),
),
),
]),
)
const decodedValues = inputValues.map((v, i) =>
@@ -46,6 +65,7 @@ export const ViewFnQuery: FC = () => {
)
const id = await addViewFnCall({
latestBlock: block.latest,
blockHash: block.hash,
pallet: entry!.pallet,
name: entry!.name,
@@ -64,13 +84,16 @@ export const ViewFnQuery: FC = () => {
)
}
const [inputValueChange$, setInputValue] = createSignal<{
export const [inputValueChange$, setInputValue] = createSignal<{
idx: number
value: Uint8Array | "partial" | null
}>()
const inputValues$ = selectedEntry$.pipeState(
const inputValues$ = viewFnEntryState.selectedEntry$.pipeState(
filter((v) => !!v),
map((v) => v.inputs),
distinctUntilChanged(
(a, b) => a.length === b.length && a.every((v, i) => b[i].type === v.type),
),
switchMap((inputs) => {
const values: Array<Uint8Array | "partial" | null> = inputs.map(() => null)
return inputValueChange$.pipe(
@@ -91,7 +114,7 @@ const isReady$ = inputValues$.pipeState(
)
const ViewFnInputValues: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const selectedEntry = useStateObservable(viewFnEntryState.selectedEntry$)
if (!selectedEntry || !selectedEntry.inputs.length) return null
return (
+48 -2
View File
@@ -3,12 +3,20 @@ import { ButtonGroup } from "@/components/ButtonGroup"
import { JsonDisplay } from "@/components/JsonDisplay"
import { workspaceEntryCtxOrAdd$ } from "@/components/Workspace"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { shortStr } from "@/utils"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, useMemo, useState } from "react"
import { FC, useEffect, useMemo, useState } from "react"
import { useParams } from "react-router-dom"
import { filter, firstValueFrom } from "rxjs"
import { setBlockHashValue } from "../Storage/BlockPicker"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import { setInputValue } from "./ViewFnQuery"
import { ViewFnWorkspaceContext } from "./ViewFnWorkspaceEntry"
import { idToViewFnCall, viewFnCallToWorkspaceEntry } from "./viewFns.state"
import {
idToViewFnCall,
viewFnCallToWorkspaceEntry,
viewFnEntryState,
} from "./viewFns.state"
export const ViewFnResults: FC = () => {
const { callId } = useParams()
@@ -36,6 +44,7 @@ const viewFnCtx$ = state(
const ViewFnResultBox: FC<{ id: string }> = ({ id }) => {
const context = useStateObservable(viewFnCtx$(id))
useSynchronizeInputs(id)
return context ? <ViewFnResultContent id={id} context={context} /> : null
}
@@ -53,6 +62,10 @@ const ViewFnResultContent: FC<{
{context.pallet}.{context.name}
</h3>
<div className="flex items-center shrink-0 gap-2">
<div className="text-xs text-center">
<p>Block</p>
<p>{shortStr(context.blockHash, 6)}</p>
</div>
<ButtonGroup
value={mode}
onValueChange={setMode as any}
@@ -122,3 +135,36 @@ const ResultDisplay: FC<{
</div>
)
}
const useSynchronizeInputs = (id: string) => {
useEffect(() => {
let cancelled = false
const run = async () => {
const params = await idToViewFnCall(id)
if (cancelled) return
setBlockHashValue(params.latestBlock ? "Latest" : params.blockHash)
viewFnEntryState.selectEntry({
group: params.pallet,
item: params.name,
})
// Let entry settle
await firstValueFrom(
viewFnEntryState.selectedEntry$.pipe(
filter(
(v) => !!v && v.pallet === params.pallet && v.name === params.name,
),
),
)
if (cancelled) return
const encodedArgs = params.args.map((arg, i) =>
params.codec.inner[i].enc(arg),
)
encodedArgs.forEach((value, idx) => setInputValue({ idx, value }))
}
run()
return () => {
cancelled = true
}
}, [id])
}
@@ -5,6 +5,7 @@ import { FC } from "react"
import type { ViewFnResult } from "./viewFns.state"
export type ViewFnWorkspaceContext = {
blockHash: string
pallet: string
name: string
result$: DefaultedStateObservable<ViewFnResult | null>
+17 -104
View File
@@ -1,115 +1,28 @@
import { DocsRenderer } from "@/components/DocsRenderer"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { MetadataEntryInput } from "@/components/MetadataEntryInput"
import { withSubscribe } from "@/components/withSuspense"
import { lookup$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { useEffect, useState } from "react"
import { Route, Routes } from "react-router-dom"
import { map } from "rxjs"
import { CenteredScrollContainer } from "../AppShell"
import { ViewFnQuery } from "./ViewFnQuery"
import { ViewFnResults } from "./ViewFnResults"
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
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]),
),
]),
),
})),
),
)
import { viewFnEntryState } from "./viewFns.state"
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 (
<CenteredScrollContainer 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 />
<Routes>
<Route path=":callId" element={<ViewFnResults />} />
</Routes>
</CenteredScrollContainer>
)
},
() => (
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<MetadataEntryInput
state={viewFnEntryState}
labels={{
group: "Pallet",
item: "Function",
}}
/>
<ViewFnQuery />
<Routes>
<Route path=":callId" element={<ViewFnResults />} />
</Routes>
</CenteredScrollContainer>
),
{
fallback: <LoadingMetadata />,
},
+59 -11
View File
@@ -1,11 +1,11 @@
import { createMetadataEntryState } from "@/components/MetadataEntryInput"
import { pushWorkspaceEntry, WorkspaceEntryData } from "@/components/Workspace"
import { getHashParams } from "@/hashParams"
import { runtimeCtxAt$, unsafeApi$ } from "@/state/chains/chain.state"
import { RuntimeContext } from "@polkadot-api/observable-client"
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { shareLatest, state } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { SquareFunction } from "lucide-react"
import { Binary, HexString, ResultPayload } from "polkadot-api"
import { Binary, Codec, HexString, ResultPayload } from "polkadot-api"
import {
catchError,
combineLatest,
@@ -21,16 +21,52 @@ import {
ViewFnWorkspaceEntry,
} from "./ViewFnWorkspaceEntry"
type Pallet = UnifiedMetadata["pallets"][number]
export type ViewFnEntry = Pallet["viewFns"][number] & {
export type ViewFnEntry = {
pallet: string
name: string
inputs: {
name: string
type: number
}[]
output: number
docs: string[]
}
export const [entryChange$, setSelectedFn] = createSignal<ViewFnEntry | null>()
export const selectedEntry$ = state(entryChange$, null)
export const viewFnEntryState = createMetadataEntryState(
(ctx) =>
Object.fromEntries(
ctx.lookup.metadata.pallets
.filter((pallet) => pallet.viewFns.length)
.map((pallet) => [
pallet.name,
pallet.viewFns.map((viewFn) => viewFn.name),
]),
),
(entries) => {
const params = getHashParams()
const group = params.get("pallet") ?? Object.keys(entries)[0] ?? null
const item =
params.get("fn") ??
params.get("function") ??
(group ? entries[group]?.[0] : null) ??
null
return { item, group }
},
(ctx, entry): ViewFnEntry => {
const pallet = ctx.lookup.metadata.pallets.find(
(pallet) => pallet.name === entry.group,
)!
const viewFn = pallet.viewFns.find((i) => i.name === entry.item)!
return {
pallet: pallet.name,
...viewFn,
}
},
)
type ViewFnCall = {
blockHash: HexString
latestBlock: boolean
pallet: string
name: string
args: unknown[]
@@ -43,20 +79,31 @@ const viewFnCallToId = (
const codec = ctx.dynamicBuilder.buildViewFn(call.pallet, call.name).args
return [
call.blockHash,
(call.latestBlock ? "latest_" : "") + call.blockHash,
call.pallet,
call.name,
Binary.toHex(codec.enc(call.args)),
].join(":")
}
export const idToViewFnCall = async (id: string): Promise<ViewFnCall> => {
const [blockHash, pallet, name, encodedArgs] = id.split(":")
export const idToViewFnCall = async (
id: string,
): Promise<
ViewFnCall & {
codec: Codec<any> & {
inner: Codec<any>[]
}
}
> => {
const [blockHashStr, pallet, name, encodedArgs] = id.split(":")
const latestBlock = blockHashStr.startsWith("latest_")
const blockHash = blockHashStr.replace("latest_", "")
const ctx = await firstValueFrom(runtimeCtxAt$(blockHash))
const codec = ctx.dynamicBuilder.buildViewFn(pallet, name).args
const args = codec.dec(encodedArgs)
return { blockHash, pallet, name, args }
return { latestBlock, blockHash, pallet, name, args, codec }
}
export const viewFnCallToWorkspaceEntry = async (
@@ -89,6 +136,7 @@ export const viewFnCallToWorkspaceEntry = async (
const context: ViewFnWorkspaceContext = {
pallet: call.pallet,
name: call.name,
blockHash: call.blockHash,
result$,
}
const id = viewFnCallToId(ctx, call)