feat: redesign storage queries (#146)

This commit is contained in:
Victor Oliva
2026-05-25 16:04:34 +02:00
committed by GitHub
parent d7af3635b6
commit 18f10af8bf
7 changed files with 250 additions and 188 deletions

View File

@@ -112,7 +112,7 @@ export const AppShell: FC<PropsWithChildren> = ({ children }) => {
<div className="flex min-w-0 flex-1 flex-col">
<TopBar onOpenSidebar={() => setSidebarOpen(true)} />
<div className="relative min-h-0 flex-1 overflow-auto">
<div className="mx-auto h-full w-full max-w-(--breakpoint-xl)">
<div className="mx-auto h-full w-full max-w-(--breakpoint-xl) @container">
{children}
</div>
</div>

View File

@@ -83,7 +83,7 @@ export const BlockPicker = () => {
return (
<div className="flex items-center">
<Input
className="bg-input"
className="bg-input max-w-52"
placeholder="Block hash or number"
value={value}
onChange={(evt) => setValue(evt.target.value)}

View File

@@ -1,95 +1,26 @@
import { ButtonGroup } from "@/components/ButtonGroup"
import { DocsRenderer } from "@/components/DocsRenderer"
import { Chopsticks } from "@/components/Icons"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { createState } from "@/lib/externalState"
import { canSetStorage$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { Route, Routes } from "react-router-dom"
import { map } from "rxjs"
import { BlockPicker, selectedBlock$ } from "./BlockPicker"
import { partialEntry$, selectedEntry$, selectEntry } from "./storage.state"
import { StorageDecode } from "./StorageDecode"
import { StorageQuery } from "./StorageQuery"
import { StorageSet } from "./StorageSet"
import { StorageSubscriptions } from "./StorageSubscriptions"
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 Storage = withSubscribe(
() => {
const { entries } = useStateObservable(metadataStorage$)
const partialEntry = useStateObservable(partialEntry$)
const selectedEntry = useStateObservable(selectedEntry$)
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-1 flex-wrap">
<label>
Block
<BlockPicker />
</label>
<div className="flex items-center gap-2 flex-wrap">
<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>
Entry
<SearchableSelect
value={partialEntry.entry}
setValue={(v) => selectEntry({ entry: v })}
options={
Object.keys(entries[partialEntry.pallet]).map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
</div>
{selectedEntry?.docs.length ? (
<div className="w-full">
Docs
<DocsRenderer docs={selectedEntry.docs} />
</div>
) : null}
<StorageEntry />
<Routes>
<Route path=":subId" element={<StorageSubscriptions />} />
</Routes>
</div>
)
},
() => (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<StorageEntry />
<Routes>
<Route path=":subId" element={<StorageSubscriptions />} />
</Routes>
</div>
),
{
fallback: <LoadingMetadata />,
},
@@ -98,21 +29,18 @@ export const Storage = withSubscribe(
export const [mode$, setMode] = createState<"query" | "decode" | "set">("query")
const StorageEntry: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const canSetStorage = useStateObservable(canSetStorage$)
const mode = useStateObservable(mode$)
if (!selectedEntry) return null
return (
<>
<div className="w-full">
<ButtonGroup
value={mode}
onValueChange={setMode as any}
items={[
{
value: "query",
content: "Query",
content: "Storage Query",
},
{
value: "decode",
@@ -124,7 +52,7 @@ const StorageEntry: FC = () => {
value: "set",
content: (
<>
Set
Set Value
<Chopsticks
className="inline-block align-middle ml-2"
size={20}
@@ -136,13 +64,15 @@ const StorageEntry: FC = () => {
: []),
]}
/>
{mode === "query" ? (
<StorageQuery />
) : mode === "decode" ? (
<StorageDecode />
) : (
<StorageSet />
)}
</>
<div className="border border-accent p-3 w-full">
{mode === "query" ? (
<StorageQuery />
) : mode === "decode" ? (
<StorageDecode />
) : (
<StorageSet />
)}
</div>
</div>
)
}

View File

@@ -8,6 +8,8 @@ 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"
export const [value$, setValue] = createState("")
@@ -52,13 +54,17 @@ export const StorageDecode: FC = () => {
}
return (
<div className="w-full">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full bg-polkadot-100 p-2 rounded tabular-nums text-polkadot-800"
placeholder="Enter hex …"
/>
<div className="w-full space-y-2">
<StorageEntryPicker />
<label className="block">
Data
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full tabular-nums"
placeholder="Enter hex …"
/>
</label>
<ActionButton disabled={decoded === NOTIN} onClick={submit}>
Decode
</ActionButton>

View File

@@ -0,0 +1,89 @@
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,7 +1,6 @@
import { EditCodec } from "@/codec-components/EditCodec"
import { ActionButton } from "@/components/ActionButton"
import { BinaryEditButton } from "@/components/BinaryEditButton"
import { CopyText } from "@/components/Copy"
import SliderToggle from "@/components/Toggle"
import { useNavigate } from "@/hashParams"
import {
@@ -9,17 +8,19 @@ import {
CodecComponentValue,
NOTIN,
} from "@polkadot-api/react-builder"
import { Input } from "@polkahub/ui-components"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal, mergeWithKey } from "@react-rxjs/utils"
import { Enum } from "polkadot-api"
import { Binary, Enum } from "polkadot-api"
import { fromHex } from "polkadot-api/utils"
import { FC } from "react"
import { ChangeEvent, FC } from "react"
import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
merge,
scan,
startWith,
switchMap,
@@ -27,23 +28,21 @@ import {
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { selectedBlock$ } from "./BlockPicker"
import { DecodedKey, decodeKey } from "./decodeKey"
import { decodeKey } from "./decodeKey"
import {
addStorageSubscription,
selectedEntry$,
selectEntry,
} from "./storage.state"
import { StorageEntryPicker } from "./StorageEntryPicker"
export const StorageQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
if (!selectedEntry) return null
const submit = async () => {
const [entry, keyValues, keysEnabled, { hash }] = await firstValueFrom(
combineLatest([selectedEntry$, keyValues$, keysEnabled$, selectedBlock$]),
combineLatest([selectedEntry$, keyValues$, argsEnabled$, selectedBlock$]),
)
const args = keyValues.slice(0, keysEnabled)
@@ -58,8 +57,9 @@ export const StorageQuery: FC = () => {
return (
<div className="flex flex-col gap-4 items-start w-full">
<KeyDisplay />
<StorageEntryPicker />
<StorageKeysInput />
<KeyInput />
<ActionButton disabled={!isReady} onClick={submit}>
Query
</ActionButton>
@@ -82,7 +82,7 @@ const hashers$ = selectedEntry$.pipeState(
const [toggleKey$, toggleKey] = createSignal<number>()
export const [changeKeysEnabled$, setKeysEnabled] = createSignal<number>()
const keysEnabled$ = keys$.pipeState(
const argsEnabled$ = keys$.pipeState(
switchMap((k) =>
mergeWithKey({
toggle: toggleKey$,
@@ -130,7 +130,7 @@ export const keyValues$ = keys$.pipeState(
)
const isReady$ = state(
combineLatest([keyValues$, keysEnabled$]).pipe(
combineLatest([keyValues$, argsEnabled$]).pipe(
map(
([keyValues, keysEnabled]) =>
keyValues.length >= keysEnabled &&
@@ -145,27 +145,30 @@ export const StorageKeysInput: FC<{
}> = ({ disableToggle }) => {
const keys = useStateObservable(keys$)
const hashers = useStateObservable(hashers$)
const keysEnabled = useStateObservable(keysEnabled$)
const argsEnabled = useStateObservable(argsEnabled$)
return (
<ol className="flex flex-col gap-2">
{keys.map((type, idx) => (
<li key={idx} className="flex flex-row gap-2 items-center">
{disableToggle ? null : (
<SliderToggle
isToggled={keysEnabled > idx}
toggle={() => toggleKey(idx)}
<div>
<label>Params</label>
<ol className="flex flex-col gap-2">
{keys.map((type, idx) => (
<li key={idx} className="flex flex-row gap-2 items-center">
{disableToggle ? null : (
<SliderToggle
isToggled={argsEnabled > idx}
toggle={() => toggleKey(idx)}
/>
)}
<StorageArgInput
idx={idx}
hasher={hashers[idx]}
type={type}
disabled={argsEnabled <= idx}
/>
)}
<StorageKeyInput
idx={idx}
hasher={hashers[idx]}
type={type}
disabled={keysEnabled <= idx}
/>
</li>
))}
</ol>
</li>
))}
</ol>
</div>
)
}
@@ -181,7 +184,7 @@ const builderState$ = state(
const keysCodec$ = combineLatest([keys$, builderState$]).pipe(
map(([keys, builder]) => keys.map((type) => builder?.buildDefinition(type))),
)
const keyInputValue$ = state(
const argInputValue$ = state(
(idx: number) =>
keyValues$.pipe(
withLatestFrom(keysCodec$),
@@ -217,14 +220,14 @@ const keyInputValue$ = state(
type: CodecComponentType.Initial,
} satisfies CodecComponentValue,
)
const StorageKeyInput: FC<{
const StorageArgInput: FC<{
idx: number
type: number
disabled: boolean
hasher: string
}> = ({ idx, type, disabled, hasher }) => {
const builder = useStateObservable(builderState$)
const value = useStateObservable(keyInputValue$(idx))
const value = useStateObservable(argInputValue$(idx))
if (!builder) return null
@@ -317,7 +320,7 @@ const keyCodec$ = state(
)
export const encodedKey$ = state(
combineLatest([keyCodec$, keyValues$, keysEnabled$]).pipe(
combineLatest([keyCodec$, keyValues$, argsEnabled$]).pipe(
map(([codec, keyValues, keysEnabled]) => {
const args = keyValues.slice(0, keysEnabled)
if (
@@ -338,69 +341,103 @@ export const encodedKey$ = state(
null,
)
export const KeyDisplay: FC = () => {
const key = useStateObservable(encodedKey$)
const [keyInputChange$, onKeyInputChange] =
createSignal<ChangeEvent<HTMLInputElement>>()
const [keyInputBlur$, onKeyInputBlur] = createSignal<void>()
const keyInput$ = state(
merge(
keyInputChange$.pipe(
map((v) => v.currentTarget.value),
withLatestFrom(selectedBlock$.pipe(map((v) => v.ctx))),
map(([value, ctx]) => {
const error =
value.length > 3 &&
(() => {
try {
return !decodeKey(ctx, Binary.fromHex(value))
} catch {
return true
}
})()
return { value, error }
}),
),
merge(encodedKey$, keyInputBlur$.pipe(switchMap(() => encodedKey$))).pipe(
map((value) => ({ value: value ?? "", error: false })),
),
),
{
value: "",
error: false,
},
)
export const KeyInput: FC = () => {
const keyInput = useStateObservable(keyInput$)
const builder = useStateObservable(builderState$)
const selectedEntry = useStateObservable(selectedEntry$)
const keysEnabled = useStateObservable(keysEnabled$)
const keysEnabled = useStateObservable(argsEnabled$)
if (!builder || !selectedEntry) return null
return (
<div className="flex w-full overflow-hidden border border-card-foreground/60 px-3 p-2 gap-2 items-center bg-card text-card-foreground">
<div className="shrink-0 text-sm font-bold">Encoded key:</div>
<div
<div className="w-full">
<label>Encoded key</label>
<Input
type="text"
className={twMerge(
"flex-1 overflow-hidden whitespace-nowrap text-ellipsis text-sm tabular-nums",
key === null ? "text-card-foreground/60" : null,
"min-w-0 w-full tabular-nums text-muted-foreground",
keyInput.error && "text-red-600",
)}
>
{key ?? "Fill in all the storage keys to calculate the encoded key"}
</div>
<CopyText text={key ?? ""} disabled={key === null} binary />
<BinaryEditButton
initialValue={key ? fromHex(key) : undefined}
onValueChange={(value: NonNullable<DecodedKey>) => {
let newKeysEnabled = keysEnabled
if (
value.pallet.name !== selectedEntry.pallet ||
value.item.name !== selectedEntry.entry
) {
selectEntry({
pallet: value.pallet.name,
entry: value.item.name,
})
newKeysEnabled =
value.item.type.tag === "plain"
? 0
: value.item.type.value.hashers.length
}
placeholder="Fill in all the storage keys to calculate the encoded key"
value={keyInput.value}
onChange={onKeyInputChange}
onBlur={() => {
onKeyInputBlur()
if (value.args.length !== newKeysEnabled) {
if (value.args.length > newKeysEnabled) {
toggleKey(value.args.length - 1)
} else {
toggleKey(value.args.length)
try {
const decoded = decodeKey(
{
dynamicBuilder: builder,
lookup: builder.lookup,
},
Binary.fromHex(keyInput.value),
)
if (!decoded) return
let newKeysEnabled = keysEnabled
if (
decoded.pallet.name !== selectedEntry.pallet ||
decoded.item.name !== selectedEntry.entry
) {
selectEntry({
pallet: decoded.pallet.name,
entry: decoded.item.name,
})
newKeysEnabled =
decoded.item.type.tag === "plain"
? 0
: decoded.item.type.value.hashers.length
}
}
value.args.forEach((value, idx) =>
setKeyValue({
idx,
value,
}),
)
}}
decode={(v) => {
const decoded = decodeKey(
{
dynamicBuilder: builder,
lookup: builder.lookup,
},
v,
)
if (!decoded) throw null
return decoded
if (decoded.args.length !== newKeysEnabled) {
if (decoded.args.length > newKeysEnabled) {
toggleKey(decoded.args.length - 1)
} else {
toggleKey(decoded.args.length)
}
}
decoded.args.forEach((value, idx) =>
setKeyValue({
idx,
value,
}),
)
} catch {
return
}
}}
/>
</div>

View File

@@ -18,7 +18,7 @@ import {
withLatestFrom,
} from "rxjs"
import { selectedEntry$ } from "./storage.state"
import { encodedKey$, KeyDisplay, StorageKeysInput } from "./StorageQuery"
import { encodedKey$, KeyInput, StorageKeysInput } from "./StorageQuery"
const [setValue$, setValue] = createSignal<Uint8Array | "partial" | null>()
const currentValue$ = state(
@@ -71,7 +71,7 @@ export const StorageSet: FC = () => {
return (
<div className="flex flex-col gap-4 items-start w-full overflow-hidden">
<KeyDisplay />
<KeyInput />
<StorageKeysInput disableToggle />
{currentValue && currentValue.encodedKey ? (
<>