From c49b3b0c6f5bfaeb3ef5374022a7344f80e0ce91 Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Thu, 28 May 2026 16:05:30 +0200 Subject: [PATCH] feat: viewFn query at block, populate args with selected query --- src/pages/ViewFns/ViewFnQuery.tsx | 37 +++++-- src/pages/ViewFns/ViewFnResults.tsx | 50 ++++++++- src/pages/ViewFns/ViewFnWorkspaceEntry.tsx | 1 + src/pages/ViewFns/ViewFns.tsx | 121 +++------------------ src/pages/ViewFns/viewFns.state.ts | 70 ++++++++++-- 5 files changed, 155 insertions(+), 124 deletions(-) diff --git a/src/pages/ViewFns/ViewFnQuery.tsx b/src/pages/ViewFns/ViewFnQuery.tsx index 5606b34..cd2e760 100644 --- a/src/pages/ViewFns/ViewFnQuery.tsx +++ b/src/pages/ViewFns/ViewFnQuery.tsx @@ -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 = 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 ( diff --git a/src/pages/ViewFns/ViewFnResults.tsx b/src/pages/ViewFns/ViewFnResults.tsx index 61faad5..97dfbf4 100644 --- a/src/pages/ViewFns/ViewFnResults.tsx +++ b/src/pages/ViewFns/ViewFnResults.tsx @@ -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 ? : null } @@ -53,6 +62,10 @@ const ViewFnResultContent: FC<{ {context.pallet}.{context.name}
+
+

Block

+

{shortStr(context.blockHash, 6)}

+
) } + +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]) +} diff --git a/src/pages/ViewFns/ViewFnWorkspaceEntry.tsx b/src/pages/ViewFns/ViewFnWorkspaceEntry.tsx index 861d0d3..cc3ca9c 100644 --- a/src/pages/ViewFns/ViewFnWorkspaceEntry.tsx +++ b/src/pages/ViewFns/ViewFnWorkspaceEntry.tsx @@ -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 diff --git a/src/pages/ViewFns/ViewFns.tsx b/src/pages/ViewFns/ViewFns.tsx index ffd4ade..5170964 100644 --- a/src/pages/ViewFns/ViewFns.tsx +++ b/src/pages/ViewFns/ViewFns.tsx @@ -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(defaultPallet) - const [fnName, setFnName] = useState(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 ( - -
- - {selectedPallet && pallet && ( - - )} -
- {!!entry?.docs.length && ( -
- Docs - -
- )} - - - } /> - -
- ) - }, + () => ( + + + + + } /> + + + ), { fallback: , }, diff --git a/src/pages/ViewFns/viewFns.state.ts b/src/pages/ViewFns/viewFns.state.ts index f17d384..a15f75a 100644 --- a/src/pages/ViewFns/viewFns.state.ts +++ b/src/pages/ViewFns/viewFns.state.ts @@ -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() -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 => { - const [blockHash, pallet, name, encodedArgs] = id.split(":") +export const idToViewFnCall = async ( + id: string, +): Promise< + ViewFnCall & { + codec: Codec & { + inner: Codec[] + } + } +> => { + 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)