runtime query at block, populate args with selected query (#148)

* refactor: abstract metadata entry input

* runtime query at block, populate args with selected query

* fix infinte update rerender, parameters not loading up

* exclude storage-less pallets from storage
This commit is contained in:
Victor Oliva
2026-05-28 22:46:02 +02:00
committed by GitHub
parent 31b6db3e60
commit 30a839a54a
12 changed files with 380 additions and 324 deletions

View File

@@ -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 = <T extends { docs?: string[] }>(
getEntries: (ctx: CachedRuntime) => Record<string, string[]>,
initialValue: (entries: Record<string, string[]>) => {
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<typeof createMetadataEntryState>
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 (
<div className="w-full">
<div className="grid grid-cols-1 gap-x-3 gap-y-2 @3xl:grid-cols-3 max-w-2xl">
<label>
Block
<BlockPicker />
</label>
<label>
{labels.group}
<SearchableSelect
value={partialEntry.group}
setValue={(v) => state.selectEntry({ group: v })}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{partialEntry.group && entries[partialEntry.group] && (
<label className="max-w-52">
<div className="flex items-center justify-between">
{labels.item}
{selectedEntry?.docs?.length ? (
<Popover
content={
<DocsRenderer
docs={selectedEntry.docs}
className="max-h-none"
/>
}
>
<button
type="button"
tabIndex={-1}
className="text-muted-foreground"
>
<Info size={16} />
</button>
</Popover>
) : null}
</div>
<SearchableSelect
value={partialEntry.item}
setValue={(v) => state.selectEntry({ item: v })}
options={
entries[partialEntry.group].map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
</div>
)
}

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,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<Uint8Array | "partial" | null> = 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 (

View File

@@ -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 ? <RuntimeCallResultContent id={id} context={context} /> : null
}
@@ -56,6 +62,10 @@ const RuntimeCallResultContent: FC<{
{context.api}.{context.method}
</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}
@@ -125,3 +135,36 @@ const ResultDisplay: FC<{
</div>
)
}
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])
}

View File

@@ -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<RuntimeCallResult | null>

View File

@@ -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<string | null>("Core")
const [method, setMethod] = useState<string | null>("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 (
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-2">
<label>
API
<SearchableSelect
value={api}
setValue={(v) => setApi(v)}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{selectedApi && api && (
<label>
Method
<SearchableSelect
value={method}
setValue={(v) => setMethod(v)}
options={
Object.keys(entries[api]).map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
{!!entry?.docs.length && (
<div className="w-full">
Docs
<DocsRenderer docs={entry.docs} />
</div>
)}
<RuntimeCallQuery />
<Routes>
<Route path=":callId" element={<RuntimeCallResults />} />
</Routes>
</CenteredScrollContainer>
)
},
() => (
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<MetadataEntryInput
state={runtimeCallEntryState}
labels={{
group: "API",
item: "Method",
}}
/>
<RuntimeCallQuery />
<Routes>
<Route path=":callId" element={<RuntimeCallResults />} />
</Routes>
</CenteredScrollContainer>
),
{
fallback: <LoadingMetadata />,
},

View File

@@ -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<RuntimeCallMetadataMethod | null>()
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<RuntimeCallQuery> => {
const [blockHash, api, method, args] = id.split(":")
): Promise<
RuntimeCallQuery & {
codec: Codec<any> & {
inner: Codec<any>[]
}
}
> => {
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)

View File

@@ -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 (
<div className="w-full space-y-2">
<StorageEntryPicker />
<MetadataEntryInput
state={storageEntryState}
labels={{
group: "Pallet",
item: "Entry",
}}
/>
<label className="block">
Data
<Textarea

View File

@@ -1,89 +0,0 @@
import { DocsRenderer } from "@/components/DocsRenderer"
import { Popover } from "@/components/Popover"
import { SearchableSelect } from "@/components/Select"
import { state, useStateObservable } from "@react-rxjs/core"
import { Info } from "lucide-react"
import { map } from "rxjs"
import { BlockPicker, selectedBlock$ } from "./BlockPicker"
import { partialEntry$, selectedEntry$, selectEntry } from "./storage.state"
const metadataStorage$ = state(
selectedBlock$.pipe(
map(({ ctx }) => ({
lookup: ctx.lookup,
entries: Object.fromEntries(
ctx.lookup.metadata.pallets
.filter((p) => p.storage)
.map((p) => [
p.name,
Object.fromEntries(
p.storage!.items.map((item) => [item.name, item.type]),
),
]),
),
})),
),
)
export const StorageEntryPicker = () => {
const { entries } = useStateObservable(metadataStorage$)
const partialEntry = useStateObservable(partialEntry$)
const selectedEntry = useStateObservable(selectedEntry$)
return (
<div className="w-full">
<div className="grid grid-cols-1 gap-x-3 gap-y-2 @3xl:grid-cols-3 max-w-2xl">
<label>
Block
<BlockPicker />
</label>
<label>
Pallet
<SearchableSelect
value={partialEntry.pallet}
setValue={(v) => selectEntry({ pallet: v })}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{partialEntry.pallet && entries[partialEntry.pallet] && (
<label className="max-w-52">
<div className="flex items-center justify-between">
Entry
{selectedEntry?.docs.length ? (
<Popover
content={
<DocsRenderer
docs={selectedEntry.docs}
className="max-h-none"
/>
}
>
<button
type="button"
tabIndex={-1}
className="text-muted-foreground"
>
<Info size={16} />
</button>
</Popover>
) : null}
</div>
<SearchableSelect
value={partialEntry.entry}
setValue={(v) => selectEntry({ entry: v })}
options={
Object.keys(entries[partialEntry.pallet]).map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { EditCodec } from "@/codec-components/EditCodec"
import { ActionButton } from "@/components/ActionButton"
import { BinaryEditButton } from "@/components/BinaryEditButton"
import { MetadataEntryInput } from "@/components/MetadataEntryInput"
import SliderToggle from "@/components/Toggle"
import { useNavigate } from "@/hashParams"
import {
@@ -29,12 +30,7 @@ import {
import { twMerge } from "tailwind-merge"
import { selectedBlock$ } from "./BlockPicker"
import { decodeKey } from "./decodeKey"
import {
addStorageSubscription,
selectedEntry$,
selectEntry,
} from "./storage.state"
import { StorageEntryPicker } from "./StorageEntryPicker"
import { addStorageSubscription, storageEntryState } from "./storage.state"
export const StorageQuery: FC = () => {
const isReady = useStateObservable(isReady$)
@@ -42,7 +38,12 @@ export const StorageQuery: FC = () => {
const submit = async () => {
const [entry, keyValues, keysEnabled, { hash }] = await firstValueFrom(
combineLatest([selectedEntry$, keyValues$, argsEnabled$, selectedBlock$]),
combineLatest([
storageEntryState.selectedEntry$,
keyValues$,
argsEnabled$,
selectedBlock$,
]),
)
const args = keyValues.slice(0, keysEnabled)
@@ -57,7 +58,13 @@ export const StorageQuery: FC = () => {
return (
<div className="flex flex-col gap-4 items-start w-full">
<StorageEntryPicker />
<MetadataEntryInput
state={storageEntryState}
labels={{
group: "Pallet",
item: "Entry",
}}
/>
<StorageKeysInput />
<KeyInput />
<ActionButton disabled={!isReady} onClick={submit}>
@@ -67,14 +74,14 @@ export const StorageQuery: FC = () => {
)
}
const keys$ = selectedEntry$.pipeState(
const keys$ = storageEntryState.selectedEntry$.pipeState(
filter((e) => !!e),
map((entry) => entry.key),
withDefault([] as number[]),
distinctUntilChanged((a, b) => a.join(",") === b.join(",")),
)
const hashers$ = selectedEntry$.pipeState(
const hashers$ = storageEntryState.selectedEntry$.pipeState(
filter((e) => !!e),
map((entry) => entry.hashers),
withDefault([] as string[]),
@@ -307,7 +314,7 @@ const StorageArgInput: FC<{
}
const keyCodec$ = state(
combineLatest([selectedBlock$, selectedEntry$]).pipe(
combineLatest([selectedBlock$, storageEntryState.selectedEntry$]).pipe(
map(([{ ctx }, selectedEntry]) =>
selectedEntry
? ctx.dynamicBuilder.buildStorage(
@@ -376,7 +383,7 @@ const keyInput$ = state(
export const KeyInput: FC = () => {
const keyInput = useStateObservable(keyInput$)
const builder = useStateObservable(builderState$)
const selectedEntry = useStateObservable(selectedEntry$)
const selectedEntry = useStateObservable(storageEntryState.selectedEntry$)
const keysEnabled = useStateObservable(argsEnabled$)
if (!builder || !selectedEntry) return null
@@ -411,9 +418,9 @@ export const KeyInput: FC = () => {
decoded.pallet.name !== selectedEntry.pallet ||
decoded.item.name !== selectedEntry.entry
) {
selectEntry({
pallet: decoded.pallet.name,
entry: decoded.item.name,
storageEntryState.selectEntry({
group: decoded.pallet.name,
item: decoded.item.name,
})
newKeysEnabled =
decoded.item.type.tag === "plain"

View File

@@ -3,9 +3,9 @@ import { Chopsticks } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import { chainClient$, client$, lookup$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils"
import { fromHex, toHex } from "polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { fromHex, toHex } from "polkadot-api/utils"
import { FC, useState } from "react"
import {
combineLatest,
@@ -17,8 +17,8 @@ import {
switchMap,
withLatestFrom,
} from "rxjs"
import { selectedEntry$ } from "./storage.state"
import { encodedKey$, KeyInput, StorageKeysInput } from "./StorageQuery"
import { storageEntryState } from "./storage.state"
const [setValue$, setValue] = createSignal<Uint8Array | "partial" | null>()
const currentValue$ = state(
@@ -33,7 +33,7 @@ const currentValue$ = state(
).pipe(
withLatestFrom(
lookup$,
selectedEntry$.pipe(filter((v) => v != null)),
storageEntryState.selectedEntry$.pipe(filter((v) => v != null)),
),
map(([v, lookup, entry]) => {
if (v != null) return fromHex(v)
@@ -59,7 +59,7 @@ const currentValue$ = state(
)
export const StorageSet: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const selectedEntry = useStateObservable(storageEntryState.selectedEntry$)
const lookup = useStateObservable(lookup$)
const currentValue = useStateObservable(currentValue$)
const [isLoading, setIsLoading] = useState(false)

View File

@@ -26,7 +26,7 @@ import { setMode } from "./Storage"
import {
idToStorageSubscription,
KeyCodec,
selectEntry,
storageEntryState,
storageSubscriptionToWorkspaceEntry,
StorageSubscriptionValue,
stringifyArg,
@@ -490,9 +490,9 @@ const useSynchronizeInputs = (id: string) => {
const params = await idToStorageSubscription(id)
if (cancelled) return
setBlockHashValue(params.blockHash ?? "Latest")
selectEntry({
pallet: params.pallet,
entry: params.item,
storageEntryState.selectEntry({
group: params.pallet,
item: params.item,
})
setMode(params.value.type)
// Let entry settle

View File

@@ -1,4 +1,5 @@
import { bytesToString } from "@/components/BinaryInput"
import { createMetadataEntryState } from "@/components/MetadataEntryInput"
import { pushWorkspaceEntry, WorkspaceEntryData } from "@/components/Workspace"
import { getHashParams } from "@/hashParams"
import {
@@ -15,13 +16,11 @@ import {
RuntimeContext,
} from "@polkadot-api/observable-client"
import { DefaultedStateObservable, state } from "@react-rxjs/core"
import { createSignal, mergeWithKey } from "@react-rxjs/utils"
import { DatabaseSearch } from "lucide-react"
import { Binary, Enum, HexString } from "polkadot-api"
import {
catchError,
combineLatest,
combineLatestWith,
distinct,
EMPTY,
endWith,
@@ -41,7 +40,6 @@ import {
take,
takeUntil,
} from "rxjs"
import { selectedBlock$ } from "./BlockPicker"
import { StorageWorkspaceEntry } from "./StorageWorkspaceEntry"
import { getEntry, getStorageItem } from "./decodeKey"
@@ -54,112 +52,53 @@ export type StorageMetadataEntry = {
hashers: string[]
}
export const [entryChange$, selectEntry] = createSignal<{
pallet?: string | null
entry?: string | null
}>()
const getPalletEntries = (
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">,
) =>
Object.fromEntries(
ctx.lookup.metadata.pallets.map((p) => [p.name, p.storage?.items ?? []]),
ctx.lookup.metadata.pallets
.filter((p) => p.storage?.items.length)
.map((p) => [p.name, p.storage?.items.map((item) => item.name) ?? []]),
)
const palletEntries$ = selectedBlock$.pipe(
map(({ ctx }) => getPalletEntries(ctx)),
)
const initialValue$ = palletEntries$.pipe(
map(() => {
export const storageEntryState = createMetadataEntryState(
getPalletEntries,
() => {
const params = getHashParams()
const pallet = params.get("pallet") ?? "System"
const entry = params.get("entry") ?? "Account"
return { entry, pallet }
}),
)
export const partialEntry$ = state(
mergeWithKey({ entryChange$, initialValue$ }).pipe(
combineLatestWith(palletEntries$),
scan(
(acc, [evt, pallets]) => {
const newValue =
evt.type === "entryChange$"
? { ...acc, ...evt.payload }
: {
pallet: acc.pallet ?? evt.payload.pallet,
entry: acc.entry ?? evt.payload.entry,
}
let selectedPallet = newValue.pallet ? pallets[newValue.pallet] : null
if (!selectedPallet) {
newValue.pallet = Object.keys(pallets)[0] ?? null
selectedPallet = pallets[newValue.pallet] ?? null
}
if (!selectedPallet?.find((it) => it.name === newValue.entry)) {
newValue.entry = selectedPallet?.[0]?.name ?? null
}
return newValue
},
{
pallet: null as string | null,
entry: null as string | null,
},
),
),
{
pallet: null,
entry: null,
const group = params.get("pallet") ?? "System"
const item = params.get("entry") ?? "Account"
return { item, group }
},
)
(ctx, entry): StorageMetadataEntry => {
const pallet = ctx.lookup.metadata.pallets.find(
(p) => p.name === entry.group,
)!
const item = pallet.storage!.items.find((i) => i.name === entry.item)!
export const selectedEntry$ = state(
combineLatest([
partialEntry$,
selectedBlock$.pipe(
map(({ ctx }) => {
const entries = getPalletEntries(ctx)
return { ctx, entries }
}),
),
]).pipe(
map(([partialEntry, { ctx, entries }]): StorageMetadataEntry | null => {
const entry = partialEntry.pallet
? entries[partialEntry.pallet]?.find(
(v) => v.name === partialEntry.entry,
)
: null
if (!entry?.type) return null
const { type, docs } = entry
const pallet = partialEntry.pallet!
const { keys } = getEntry(ctx, type)
const key = keys.map((v) => v.type)
const hashers = keys.map((v) => v.hasher)
if (type.tag === "plain") {
return {
value: type.value,
key,
pallet,
entry: entry.name,
docs,
hashers,
}
}
const { keys } = getEntry(ctx, item.type)
const key = keys.map((v) => v.type)
const hashers = keys.map((v) => v.hasher)
if (item.type.tag === "plain") {
return {
value: type.value.value,
value: item.type.value,
key,
pallet,
entry: entry.name,
docs,
pallet: pallet.name,
entry: item.name,
docs: item.docs,
hashers,
}
}),
),
null,
}
return {
value: item.type.value.value,
key,
pallet: pallet.name,
entry: item.name,
docs: item.docs,
hashers,
}
},
)
export type KeyCodec = {