diff --git a/src/components/MetadataEntryInput.tsx b/src/components/MetadataEntryInput.tsx new file mode 100644 index 0000000..11b728b --- /dev/null +++ b/src/components/MetadataEntryInput.tsx @@ -0,0 +1,161 @@ +import { BlockPicker, selectedBlock$ } from "@/pages/Storage/BlockPicker" +import { CachedRuntime } from "@/state/chains/chain.state" +import { state, useStateObservable } from "@react-rxjs/core" +import { createSignal, mergeWithKey } from "@react-rxjs/utils" +import { Info } from "lucide-react" +import { FC } from "react" +import { combineLatest, defer, map, scan } from "rxjs" +import { DocsRenderer } from "./DocsRenderer" +import { Popover } from "./Popover" +import { SearchableSelect } from "./Select" + +export const createMetadataEntryState = ( + getEntries: (ctx: CachedRuntime) => Record, + initialValue: (entries: Record) => { + group: string | null + item: string | null + }, + getEntry: (ctx: CachedRuntime, entry: { group: string; item: string }) => T, +) => { + const [entryChange$, selectEntry] = createSignal<{ + group?: string | null + item?: string | null + }>() + + const groupEntries$ = state( + selectedBlock$.pipe(map(({ ctx }) => getEntries(ctx))), + {}, + ) + + const emptyEntry = { + group: null as string | null, + item: null as string | null, + } + const initialValue$ = groupEntries$.pipe(map(initialValue)) + const partialSelection$ = defer(() => + mergeWithKey({ entryChange$, initialValue$ }).pipe( + scan((acc, evt) => { + if (evt.type === "initialValue$") { + if (!acc.group) return evt.payload + return acc + } + return { + group: evt.payload.group ?? acc.group, + item: evt.payload.item ?? acc.item, + } + }, emptyEntry), + ), + ) + + const partialEntry$ = state( + combineLatest([partialSelection$, groupEntries$]).pipe( + map(([partialSelection, entries]) => { + const result = { ...partialSelection } + + let selectedGroup = result.group ? entries[result.group] : null + if (!selectedGroup) { + result.group = Object.keys(entries)[0] ?? null + if (!result.group) return emptyEntry + + selectedGroup = entries[result.group] ?? null + } + if (!result.item || !selectedGroup.includes(result.item)) { + result.item = selectedGroup?.[0] ?? null + } + return result + }), + ), + emptyEntry, + ) + + const selectedEntry$ = state( + combineLatest([partialEntry$, selectedBlock$]).pipe( + map(([partialEntry, { ctx }]): T | null => { + const { group, item } = partialEntry + if (!group || !item) return null + const entries = getEntries(ctx) + if (!entries[group]?.includes(item)) return null + + return getEntry(ctx, { group, item }) + }), + ), + null, + ) + + return { + selectEntry, + groupEntries$, + partialEntry$, + selectedEntry$, + } +} +export type MetadataEntryState = ReturnType + +export const MetadataEntryInput: FC<{ + state: MetadataEntryState + labels: { + group: string + item: string + } +}> = ({ state, labels }) => { + const entries = useStateObservable(state.groupEntries$) + const partialEntry = useStateObservable(state.partialEntry$) + const selectedEntry = useStateObservable(state.selectedEntry$) + + return ( +
+
+ + + {partialEntry.group && entries[partialEntry.group] && ( + + )} +
+
+ ) +} diff --git a/src/pages/RuntimeCalls/RuntimeCallQuery.tsx b/src/pages/RuntimeCalls/RuntimeCallQuery.tsx index 31965d5..7911fd9 100644 --- a/src/pages/RuntimeCalls/RuntimeCallQuery.tsx +++ b/src/pages/RuntimeCalls/RuntimeCallQuery.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,14 @@ import { switchMap, } from "rxjs" import { twMerge } from "tailwind-merge" -import { addRuntimeCallQuery, selectedEntry$ } from "./runtimeCalls.state" +import { selectedBlock$ } from "../Storage/BlockPicker" +import { + addRuntimeCallQuery, + runtimeCallEntryState, +} from "./runtimeCalls.state" export const RuntimeCallQuery: FC = () => { - const selectedEntry = useStateObservable(selectedEntry$) + const selectedEntry = useStateObservable(runtimeCallEntryState.selectedEntry$) const isReady = useStateObservable(isReady$) const navigate = useNavigate() @@ -33,10 +38,27 @@ export const RuntimeCallQuery: FC = () => { const submit = async () => { const [entry, inputValues, builder, block] = await firstValueFrom( combineLatest([ - selectedEntry$, + runtimeCallEntryState.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 +68,7 @@ export const RuntimeCallQuery: FC = () => { ) const id = await addRuntimeCallQuery({ + latestBlock: block.latest, blockHash: block.hash, api: entry!.api, method: entry!.name, @@ -64,13 +87,16 @@ export const RuntimeCallQuery: FC = () => { ) } -const [inputValueChange$, setInputValue] = createSignal<{ +export const [inputValueChange$, setInputValue] = createSignal<{ idx: number value: Uint8Array | "partial" | null }>() -const inputValues$ = selectedEntry$.pipeState( +const inputValues$ = runtimeCallEntryState.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 +117,7 @@ const isReady$ = inputValues$.pipeState( ) const RuntimeInputValues: FC = () => { - const selectedEntry = useStateObservable(selectedEntry$) + const selectedEntry = useStateObservable(runtimeCallEntryState.selectedEntry$) if (!selectedEntry || !selectedEntry.inputs.length) return null return ( diff --git a/src/pages/RuntimeCalls/RuntimeCallResults.tsx b/src/pages/RuntimeCalls/RuntimeCallResults.tsx index f80c192..3917991 100644 --- a/src/pages/RuntimeCalls/RuntimeCallResults.tsx +++ b/src/pages/RuntimeCalls/RuntimeCallResults.tsx @@ -3,13 +3,18 @@ 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 "./RuntimeCallQuery" import { RuntimeCallWorkspaceContext } from "./RuntimeCallWorkspaceEntry" import { idToRuntimeQuery, + runtimeCallEntryState, runtimeCallToWorkspaceEntry, } from "./runtimeCalls.state" @@ -39,6 +44,7 @@ const runtimeCallCtx$ = state( const RuntimeCallResultBox: FC<{ id: string }> = ({ id }) => { const context = useStateObservable(runtimeCallCtx$(id)) + useSynchronizeInputs(id) return context ? : null } @@ -56,6 +62,10 @@ const RuntimeCallResultContent: FC<{ {context.api}.{context.method}
+
+

Block

+

{shortStr(context.blockHash, 6)}

+
) } + +const useSynchronizeInputs = (id: string) => { + useEffect(() => { + let cancelled = false + const run = async () => { + const params = await idToRuntimeQuery(id) + if (cancelled) return + setBlockHashValue(params.latestBlock ? "Latest" : params.blockHash) + runtimeCallEntryState.selectEntry({ + group: params.api, + item: params.method, + }) + // Let entry settle + await firstValueFrom( + runtimeCallEntryState.selectedEntry$.pipe( + filter( + (v) => !!v && v.api === params.api && v.name === params.method, + ), + ), + ) + 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/RuntimeCalls/RuntimeCallWorkspaceEntry.tsx b/src/pages/RuntimeCalls/RuntimeCallWorkspaceEntry.tsx index c2cb342..4242b4e 100644 --- a/src/pages/RuntimeCalls/RuntimeCallWorkspaceEntry.tsx +++ b/src/pages/RuntimeCalls/RuntimeCallWorkspaceEntry.tsx @@ -5,6 +5,7 @@ import { FC } from "react" import type { RuntimeCallResult } from "./runtimeCalls.state" export type RuntimeCallWorkspaceContext = { + blockHash: string api: string method: string result$: DefaultedStateObservable diff --git a/src/pages/RuntimeCalls/RuntimeCalls.tsx b/src/pages/RuntimeCalls/RuntimeCalls.tsx index c109f53..ab400e7 100644 --- a/src/pages/RuntimeCalls/RuntimeCalls.tsx +++ b/src/pages/RuntimeCalls/RuntimeCalls.tsx @@ -1,105 +1,28 @@ -import { lookup$ } from "@/state/chains/chain.state" -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 { 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 { RuntimeCallQuery } from "./RuntimeCallQuery" import { RuntimeCallResults } from "./RuntimeCallResults" -import { selectedEntry$, setSelectedMethod } from "./runtimeCalls.state" -import { CenteredScrollContainer } from "../AppShell" - -const metadataRuntimeCalls$ = state( - lookup$.pipe( - map((lookup) => ({ - lookup, - entries: Object.fromEntries( - lookup.metadata.apis.map((p) => [ - p.name, - Object.fromEntries(p.methods.map((method) => [method.name, method])), - ]), - ), - })), - ), -) +import { runtimeCallEntryState } from "./runtimeCalls.state" export const RuntimeCalls = withSubscribe( - () => { - const { lookup, entries } = useStateObservable(metadataRuntimeCalls$) - const [api, setApi] = useState("Core") - const [method, setMethod] = useState("Version") - const entry = useStateObservable(selectedEntry$) - - const selectedApi = - (api && lookup.metadata.apis.find((p) => p.name === api)) || null - - useEffect( - () => - setMethod((prev) => { - if (!selectedApi?.methods[0]) return null - return selectedApi.methods.some((v) => v.name === prev) - ? prev - : selectedApi.methods[0].name - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedApi?.name], - ) - - useEffect(() => { - const selectedMethod = - (method && selectedApi?.methods.find((it) => it.name === method)) || - null - setSelectedMethod( - selectedMethod ? { ...selectedMethod, api: selectedApi!.name } : null, - ) - }, [selectedApi, method]) - - return ( - -
- - {selectedApi && api && ( - - )} -
- {!!entry?.docs.length && ( -
- Docs - -
- )} - - - } /> - -
- ) - }, + () => ( + + + + + } /> + + + ), { fallback: , }, diff --git a/src/pages/RuntimeCalls/runtimeCalls.state.ts b/src/pages/RuntimeCalls/runtimeCalls.state.ts index 77769b6..aa20036 100644 --- a/src/pages/RuntimeCalls/runtimeCalls.state.ts +++ b/src/pages/RuntimeCalls/runtimeCalls.state.ts @@ -1,10 +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 { state } from "@react-rxjs/core" -import { createSignal } from "@react-rxjs/utils" import { ServerCog } from "lucide-react" -import { Binary, HexString, ResultPayload } from "polkadot-api" +import { Binary, Codec, HexString, ResultPayload } from "polkadot-api" import { catchError, combineLatest, @@ -31,12 +32,35 @@ export type RuntimeCallMetadataMethod = { docs: string[] } -export const [entryChange$, setSelectedMethod] = - createSignal() -export const selectedEntry$ = state(entryChange$, null) +export const runtimeCallEntryState = createMetadataEntryState( + (ctx) => + Object.fromEntries( + ctx.lookup.metadata.apis.map((api) => [ + api.name, + api.methods.map((method) => method.name), + ]), + ), + () => { + const params = getHashParams() + const group = params.get("api") ?? "Core" + const item = params.get("method") ?? "Version" + return { item, group } + }, + (ctx, entry): RuntimeCallMetadataMethod => { + const api = ctx.lookup.metadata.apis.find( + (api) => api.name === entry.group, + )! + const method = api.methods.find((i) => i.name === entry.item)! + return { + api: api.name, + ...method, + } + }, +) type RuntimeCallQuery = { blockHash: HexString + latestBlock: boolean api: string method: string args: unknown[] @@ -49,7 +73,7 @@ const runtimeQueryToId = ( const codec = ctx.dynamicBuilder.buildRuntimeCall(query.api, query.method) return [ - query.blockHash, + (query.latestBlock ? "latest_" : "") + query.blockHash, query.api, query.method, Binary.toHex(codec.args.enc(query.args)), @@ -57,16 +81,27 @@ const runtimeQueryToId = ( } export const idToRuntimeQuery = async ( id: string, -): Promise => { - const [blockHash, api, method, args] = id.split(":") +): Promise< + RuntimeCallQuery & { + codec: Codec & { + inner: Codec[] + } + } +> => { + const [blockHashStr, api, method, args] = id.split(":") + const latestBlock = blockHashStr.startsWith("latest_") + const blockHash = blockHashStr.replace("latest_", "") + const ctx = await firstValueFrom(runtimeCtxAt$(blockHash)) const codec = ctx.dynamicBuilder.buildRuntimeCall(api, method).args return { + latestBlock, blockHash, api, method, args: codec.dec(args), + codec, } } export const runtimeCallToWorkspaceEntry = async ( @@ -98,6 +133,7 @@ export const runtimeCallToWorkspaceEntry = async ( const context: RuntimeCallWorkspaceContext = { api: query.api, method: query.method, + blockHash: query.blockHash, result$, } const id = runtimeQueryToId(ctx, query) diff --git a/src/pages/Storage/StorageDecode.tsx b/src/pages/Storage/StorageDecode.tsx index d7991f0..7ff74d8 100644 --- a/src/pages/Storage/StorageDecode.tsx +++ b/src/pages/Storage/StorageDecode.tsx @@ -1,4 +1,6 @@ import { ActionButton } from "@/components/ActionButton" +import { MetadataEntryInput } from "@/components/MetadataEntryInput" +import { Textarea } from "@/components/ui/textarea" import { useNavigate } from "@/hashParams" import { createState } from "@/lib/externalState" import { NOTIN } from "@polkadot-api/react-builder" @@ -7,13 +9,14 @@ import { Enum } from "polkadot-api" import { FC } from "react" import { combineLatest, firstValueFrom, map } from "rxjs" import { selectedBlock$ } from "./BlockPicker" -import { addStorageSubscription, selectedEntry$ } from "./storage.state" -import { StorageEntryPicker } from "./StorageEntryPicker" -import { Textarea } from "@/components/ui/textarea" +import { addStorageSubscription, storageEntryState } from "./storage.state" export const [value$, setValue] = createState("") -const valueDecoder$ = combineLatest([selectedEntry$, selectedBlock$]).pipe( +const valueDecoder$ = combineLatest([ + storageEntryState.selectedEntry$, + selectedBlock$, +]).pipe( map(([selectedEntry, { ctx }]) => selectedEntry ? ctx.dynamicBuilder.buildDefinition(selectedEntry.value).dec @@ -41,7 +44,7 @@ export const StorageDecode: FC = () => { const submit = async () => { const [entry, { hash }] = await firstValueFrom( - combineLatest([selectedEntry$, selectedBlock$]), + combineLatest([storageEntryState.selectedEntry$, selectedBlock$]), ) const id = await addStorageSubscription({ @@ -55,7 +58,13 @@ export const StorageDecode: FC = () => { return (
- +