Merge pull request #101 from polkadot-api/vo/storage-keys

feat: show decoded keys on storage get entries
This commit is contained in:
Victor Oliva
2025-11-12 16:54:29 -03:00
committed by GitHub
12 changed files with 374 additions and 41 deletions

View File

@@ -0,0 +1,41 @@
import { useAppendTitle } from "@/codec-components/EditCodec/Tree/CEnum"
import { Enum } from "polkadot-api"
import { FC, useContext, useState } from "react"
import { Portal } from "react-portal"
import { twMerge } from "tailwind-merge"
import { ChildProvider, TitleContext } from "./TitleContext"
import { ViewValue } from "./ViewValue"
export const EnumDisplay: FC<{
value: Enum<Record<string, unknown>>
}> = ({ value }) => {
const titleContainer = useContext(TitleContext)
const titleElement = useAppendTitle(titleContainer, "")
const [newElement, setNewElement] = useState<HTMLElement | null>(null)
const inner = <ViewValue value={value.value} />
if (titleContainer) {
return (
<>
{titleElement ? (
<Portal node={titleElement}>/ {value.type}</Portal>
) : null}
{inner}
</>
)
}
return (
<div className={twMerge("flex flex-col")}>
<div className="flex gap-2 overflow-hidden justify-between">
<div ref={setNewElement} className="flex gap-1 flex-wrap">
<span>{value.type}</span>
</div>
</div>
<div className="flex flex-col pt-1">
<ChildProvider titleElement={newElement}>{inner}</ChildProvider>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { FC, PropsWithChildren } from "react"
import { ChildProvider } from "./TitleContext"
import { ViewValue } from "./ViewValue"
export const ArrayDisplay: FC<{ value: unknown[] }> = ({ value }) => (
<ul className="w-full">
{value.length ? (
value.map((innerValue, idx) => (
<ListItemComponent key={idx} idx={idx}>
<ViewValue value={innerValue} />
</ListItemComponent>
))
) : (
<span className="text-sm text-foreground/60">(Empty)</span>
)}
</ul>
)
const ListItemComponent: FC<
PropsWithChildren<{
idx: number
}>
> = ({ idx, children }) => {
return (
<ChildProvider titleElement={null}>
<ListItem idx={idx}>{children}</ListItem>
</ChildProvider>
)
}
const ListItem: React.FC<
PropsWithChildren<{
idx: number
}>
> = ({ idx, children }) => {
const title = (
<div className="flex items-center">
<span className="cursor-pointer flex items-center py-1 gap-1">
Item {idx + 1}.
</span>
</div>
)
return (
<li className={"flex flex-col mb-1"}>
{title}
<div className={"flex-row p-2 items-center border border-border"}>
{children}
</div>
</li>
)
}

View File

@@ -0,0 +1,75 @@
import { Dot } from "lucide-react"
import React, { FC, PropsWithChildren, useContext, useState } from "react"
import { twMerge } from "tailwind-merge"
import { ChildProvider, TitleContext } from "./TitleContext"
import { ViewValue } from "./ViewValue"
import { ExpandBtn } from "@/components/Expand"
const isComplexNested = (value: unknown) => {
if (typeof value !== "object" || !value) return false
if (Array.isArray(value)) return value.length > 1
if (Object.keys(value).length === 2 && "type" in value && "value" in value) {
return isComplexNested(value.value)
}
return true
}
const StructItem: React.FC<
PropsWithChildren<{
name: string
value: unknown
}>
> = ({ name, value, children }) => {
const [titleElement, setTitleElement] = useState<HTMLElement | null>(null)
const [expanded, setExpanded] = useState(true)
const isComplexShape = isComplexNested(value)
return (
<li
className={twMerge(
"flex flex-col transition-all duration-300",
isComplexShape ? "cursor-pointer" : "",
)}
onClick={() => setExpanded((e) => !e)}
>
<ChildProvider titleElement={titleElement}>
<span className="flex items-center py-1 gap-1">
{isComplexShape ? (
<ExpandBtn expanded={expanded} />
) : (
<Dot size={16} />
)}
<span className="flex items-center gap-1">
<span className="opacity-75">{name}</span>
<span ref={setTitleElement} />
</span>
{isComplexShape ? null : <div>{children}</div>}
</span>
{isComplexShape && expanded ? <div>{children}</div> : null}
</ChildProvider>
</li>
)
}
export const StructDisplay: FC<{ value: Record<string, unknown> }> = ({
value,
}) => {
const hasParentTitle = !!useContext(TitleContext)
return (
<ul
className={twMerge(
"flex flex-col w-full",
hasParentTitle && "border-l border-border",
)}
>
{Object.entries(value).map(([name, value]) => (
<StructItem key={name} name={name} value={value}>
<ViewValue value={value} />
</StructItem>
))}
</ul>
)
}

View File

@@ -0,0 +1,9 @@
import { createContext, FC, PropsWithChildren } from "react"
export const TitleContext = createContext<HTMLElement | null>(null)
export const ChildProvider: FC<
PropsWithChildren<{ titleElement: HTMLElement | null }>
> = ({ titleElement, children }) => (
<TitleContext.Provider value={titleElement}>{children}</TitleContext.Provider>
)

View File

@@ -0,0 +1,51 @@
import { BytesDisplay } from "@/codec-components/ViewCodec/CBytes"
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { Binary, getSs58AddressInfo } from "polkadot-api"
import { FC } from "react"
import { EnumDisplay } from "./EnumDisplay"
import { ArrayDisplay } from "./ListComponents"
import { StructDisplay } from "./StructDisplay"
import {
BoolDisplay,
EthAccountDisplay,
NoneDisplay,
NumberDisplay,
ResultDisplay,
StrDisplay,
} from "./view-components"
export const ViewValue: FC<{
value: unknown
}> = ({ value }) => {
switch (typeof value) {
case "string": {
const info = getSs58AddressInfo(value)
if (info.isValid) return <AccountIdDisplay value={value} />
if (value.startsWith("0x") && value.length === 42)
return <EthAccountDisplay value={value} />
return <StrDisplay value={value} />
}
case "boolean":
return <BoolDisplay value={value} />
case "number":
case "bigint":
return <NumberDisplay value={value} />
case "object": {
if (value == null) return <>TODO</>
if (value instanceof Binary) return <BytesDisplay value={value} />
if (Array.isArray(value)) return <ArrayDisplay value={value} />
if ("type" in value && typeof value.type === "string" && "value" in value)
return <EnumDisplay value={value as any} />
if (
"success" in value &&
typeof value.success === "boolean" &&
"value" in value
)
return <ResultDisplay value={value as any} />
return <StructDisplay value={value as any} />
}
case "undefined":
return <NoneDisplay />
}
return <div className="text-muted-foreground">(Uknown value)</div>
}

1
src/ViewValue/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./ViewValue"

18
src/ViewValue/utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import { getEnumInnerVar, isComplex } from "@/utils/shape"
import { Var } from "@polkadot-api/metadata-builders"
export const isComplexNested = (field: Var, value: any): boolean => {
if (!isComplex(field.type)) return false
if (field.type === "enum")
return isComplexNested(getEnumInnerVar(field, value.type), value.value)
if (field.type === "option")
return value == null ? false : isComplexNested(field.value, value)
if (field.type === "sequence" && value.length === 0) {
return false
}
return true
}

View File

@@ -0,0 +1,33 @@
import { HexString } from "polkadot-api"
import { FC } from "react"
import { ViewValue } from "./ViewValue"
export const BoolDisplay: FC<{ value: boolean }> = ({ value }) => {
return <div className="flex gap-4">{value ? "Yes" : "No"}</div>
}
export const EthAccountDisplay: FC<{ value: HexString }> = ({ value }) => (
<span>{value}</span>
)
export const NoneDisplay: FC = () => (
<span className="text-foreground/60">None</span>
)
export const ResultDisplay: FC<{
value: { success: boolean; value: unknown }
}> = ({ value }) => {
return (
<div>
<div>{value.success ? "OK" : "KO"}</div>
<ViewValue value={value.value} />
</div>
)
}
export const StrDisplay: FC<{ value: string }> = ({ value }) => (
<div>{value}</div>
)
export const NumberDisplay: FC<{ value: number | bigint }> = ({ value }) => (
<div>{String(value)}</div>
)

View File

@@ -1,13 +1,19 @@
import { getBytesFormat } from "@/components/BinaryInput"
import { ViewBytes } from "@polkadot-api/react-builder"
import { useReportBinary } from "./CopyBinary"
import { SwitchBinary } from "@/components/Icons"
import { useState } from "react"
import { ViewBytes } from "@polkadot-api/react-builder"
import { Binary } from "polkadot-api"
import { FC, useState } from "react"
import { useReportBinary } from "./CopyBinary"
export const CBytes: ViewBytes = ({ value, encodedValue }) => {
useReportBinary(encodedValue)
return <BytesDisplay value={value} />
}
export const BytesDisplay: FC<{ value: Binary }> = ({ value }) => {
const [forceBinary, setForceBinary] = useState(false)
useReportBinary(encodedValue)
const format = getBytesFormat(value)
return (

View File

@@ -46,9 +46,16 @@ export const StorageQuery: FC = () => {
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, keyValues, keysEnabled] = await firstValueFrom(
combineLatest([selectedEntry$, unsafeApi$, keyValues$, keysEnabled$]),
)
const [entry, unsafeApi, keyValues, keysEnabled, keyCodec] =
await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
keyValues$,
keysEnabled$,
keyCodec$,
]),
)
const args = keyValues.slice(0, keysEnabled)
const storageEntry = unsafeApi.query[entry!.pallet][entry!.entry]
const single = keyValues.length === keysEnabled
@@ -64,6 +71,7 @@ export const StorageQuery: FC = () => {
single,
stream,
type: entry!.value,
keyCodec: keyCodec!,
})
}
@@ -303,29 +311,30 @@ const StorageKeyInput: FC<{
)
}
const keyCodec$ = state(
combineLatest([dynamicBuilder$, selectedEntry$]).pipe(
map(([builder, selectedEntry]) =>
selectedEntry
? builder.buildStorage(selectedEntry.pallet, selectedEntry.entry).keys
: null,
),
),
)
export const encodedKey$ = state(
combineLatest([
dynamicBuilder$,
selectedEntry$,
keyValues$,
keysEnabled$,
]).pipe(
map(([builder, selectedEntry, keyValues, keysEnabled]) => {
combineLatest([keyCodec$, keyValues$, keysEnabled$]).pipe(
map(([codec, keyValues, keysEnabled]) => {
const args = keyValues.slice(0, keysEnabled)
if (
keyValues.length < keysEnabled ||
!args.every((v) => v !== NOTIN) ||
!selectedEntry
!codec
) {
return null
}
const codec = builder.buildStorage(
selectedEntry.pallet,
selectedEntry.entry,
)
try {
return codec.keys.enc(...args)
return codec.enc(...args)
} catch (_) {
return null
}

View File

@@ -1,22 +1,25 @@
import { dynamicBuilder$, metadata$ } from "@/state/chains/chain.state"
import { PathsRoot } from "@/codec-components/common/paths.state"
import { ViewCodec } from "@/codec-components/ViewCodec"
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { ButtonGroup } from "@/components/ButtonGroup"
import { JsonDisplay } from "@/components/JsonDisplay"
import { dynamicBuilder$, metadata$ } from "@/state/chains/chain.state"
import { ViewValue } from "@/ViewValue"
import { CodecComponentType, NOTIN } from "@polkadot-api/react-builder"
import { state, useStateObservable } from "@react-rxjs/core"
import { PauseCircle, PlayCircle, Trash2 } from "lucide-react"
import { Binary } from "polkadot-api"
import { FC, useMemo, useState } from "react"
import { Virtuoso } from "react-virtuoso"
import {
KeyCodec,
removeStorageSubscription,
StorageSubscription,
storageSubscription$,
storageSubscriptionKeys$,
stringifyArg,
toggleSubscriptionPause,
} from "./storage.state"
import { PathsRoot } from "@/codec-components/common/paths.state"
import { cn } from "@/lib/utils"
export const StorageSubscriptions: FC = () => {
const keys = useStateObservable(storageSubscriptionKeys$)
@@ -131,24 +134,19 @@ const DecodedResultDisplay: FC<{
value: unknown
}>
const renderItem = (keyArgs: unknown[], value: unknown, idx: number) => {
const title = keyArgs
.slice(storageSubscription.args?.length ?? 0)
.map(stringifyArg)
.join(", ")
return (
<div key={idx} className={itemClasses}>
<PathsRoot.Provider value={`${subscriptionKey}-${idx}`}>
<ValueDisplay
mode="decoded"
title={title}
value={value}
type={storageSubscription.type}
/>
</PathsRoot.Provider>
</div>
)
}
const renderItem = (keyArgs: unknown[], value: unknown, idx: number) => (
<div key={idx} className={itemClasses}>
<PathsRoot.Provider value={`${subscriptionKey}-${idx}`}>
<KeyDisplay value={keyArgs} keyCodec={storageSubscription.keyCodec} />
<ValueDisplay
mode="decoded"
title="Value"
value={value}
type={storageSubscription.type}
/>
</PathsRoot.Provider>
</div>
)
if (values.length > 10) {
return (
@@ -251,3 +249,37 @@ export const ValueDisplay: FC<{
</div>
)
}
const KeyDisplay: FC<{
value: unknown[]
keyCodec?: KeyCodec
}> = ({ value, keyCodec }) => {
const binaryValue = (() => {
try {
return keyCodec ? Binary.fromHex(keyCodec.enc(...value)).asBytes() : null
} catch (_) {
return null
}
})()
return (
<div>
<div className="flex flex-1 gap-2 overflow-hidden">
{binaryValue ? <CopyBinary value={binaryValue} /> : null}
<h3 className="overflow-hidden text-ellipsis">Key</h3>
</div>
<ol className="leading-tight flex gap-1 items-center flex-wrap">
{value.map((v, i) => (
<li
key={i}
className={cn("px-1 py-0.5", {
"border rounded": value.length > 1,
})}
>
<ViewValue value={v} />
</li>
))}
</ol>
</div>
)
}

View File

@@ -150,10 +150,15 @@ export const selectedEntry$ = state(
null,
)
export type KeyCodec = {
enc: (...args: any[]) => string
dec: (value: string) => any[]
}
export const [newStorageSubscription$, addStorageSubscription] = createSignal<{
name: string
args: unknown[] | null
type: number
keyCodec?: KeyCodec
single: boolean
stream: Observable<unknown>
}>()
@@ -166,6 +171,7 @@ export type StorageSubscription = {
name: string
args: unknown[] | null
type: number
keyCodec?: KeyCodec
single: boolean
paused: boolean
completed: boolean