Merge pull request #103 from polkadot-api/vo/view-fns
feat: add view functions
This commit is contained in:
+3
-1
@@ -1,3 +1,4 @@
|
||||
import { VaultTxModal } from "polkahub"
|
||||
import { Navigate, Route, Routes } from "react-router-dom"
|
||||
import { Accounts } from "./pages/Accounts/Accounts"
|
||||
import { Constants } from "./pages/Constants"
|
||||
@@ -9,7 +10,7 @@ import { RpcCalls } from "./pages/RpcCalls"
|
||||
import { RuntimeCalls } from "./pages/RuntimeCalls"
|
||||
import { Storage } from "./pages/Storage"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
import { VaultTxModal } from "polkahub"
|
||||
import { ViewFns } from "./pages/ViewFns"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -26,6 +27,7 @@ export default function App() {
|
||||
<Route path="rpcCalls/*" element={<RpcCalls />} />
|
||||
<Route path="metadata/*" element={<Metadata />} />
|
||||
<Route path="accounts/*" element={<Accounts />} />
|
||||
<Route path="viewFns/*" element={<ViewFns />} />
|
||||
<Route path="*" element={<Navigate to="/explorer" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,9 @@ export const Header = () => {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<NavLink to="/viewFns" onClick={() => setIsOpen(false)}>
|
||||
View Functions
|
||||
</NavLink>
|
||||
<NavLink to="/metadata" onClick={() => setIsOpen(false)}>
|
||||
Metadata
|
||||
</NavLink>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CopyText } from "@/components/Copy"
|
||||
import { SearchableSelect } from "@/components/Select"
|
||||
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
|
||||
import { FC, useEffect, useState } from "react"
|
||||
import { FC, Fragment, useState } from "react"
|
||||
import { LookupLink } from "./Lookup"
|
||||
import { CopyText } from "@/components/Copy"
|
||||
|
||||
type Pallet = UnifiedMetadata["pallets"][number]
|
||||
export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
|
||||
@@ -22,7 +22,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
|
||||
/>
|
||||
</label>
|
||||
{pallet && (
|
||||
<>
|
||||
<Fragment key={pallet.name}>
|
||||
<p>Index: {pallet.index}</p>
|
||||
{pallet.storage && (
|
||||
<div>
|
||||
@@ -36,6 +36,12 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
|
||||
<PalletConstants pallet={pallet} />
|
||||
</div>
|
||||
)}
|
||||
{pallet.viewFns.length > 0 && (
|
||||
<div>
|
||||
<h4>View Functions</h4>
|
||||
<PalletViewFns pallet={pallet} />
|
||||
</div>
|
||||
)}
|
||||
{pallet.calls != null && (
|
||||
<div>
|
||||
<h4>Calls</h4>
|
||||
@@ -54,7 +60,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
|
||||
<LookupLink id={pallet.errors.type} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -68,10 +74,6 @@ type StorageEntry = Pallet["storage"] extends
|
||||
const PalletStorage: FC<{ pallet: Pallet }> = ({ pallet }) => {
|
||||
const [entry, setEntry] = useState<StorageEntry | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(null)
|
||||
}, [pallet])
|
||||
|
||||
if (!pallet.storage) return null
|
||||
|
||||
const value =
|
||||
@@ -129,10 +131,6 @@ type ConstantEntry = Pallet["constants"] extends Array<infer R> ? R : never
|
||||
const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
|
||||
const [entry, setEntry] = useState<ConstantEntry | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(null)
|
||||
}, [pallet])
|
||||
|
||||
if (!pallet.constants.length) return null
|
||||
|
||||
return (
|
||||
@@ -166,3 +164,42 @@ const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ViewFnEntry = Pallet["viewFns"] extends Array<infer R> ? R : never
|
||||
const PalletViewFns: FC<{ pallet: Pallet }> = ({ pallet }) => {
|
||||
const [entry, setEntry] = useState<ViewFnEntry | null>(null)
|
||||
|
||||
if (!pallet.viewFns.length) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-2 gap-2">
|
||||
<SearchableSelect
|
||||
value={entry}
|
||||
setValue={setEntry}
|
||||
options={pallet.viewFns.map((c) => ({
|
||||
text: c.name,
|
||||
value: c,
|
||||
}))}
|
||||
/>
|
||||
{entry && (
|
||||
<>
|
||||
<div>
|
||||
<h4>Inputs</h4>
|
||||
<ol>
|
||||
{entry.inputs.map((input) => (
|
||||
<li key={input.name}>
|
||||
<div>{input.name}</div>
|
||||
<LookupLink id={input.type} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Output</h4>
|
||||
<LookupLink id={entry.output} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
dynamicBuilder$,
|
||||
lookup$,
|
||||
unsafeApi$,
|
||||
} from "@/state/chains/chain.state"
|
||||
import {
|
||||
InlineLookupTypeEdit,
|
||||
LookupTypeEdit,
|
||||
} from "@/codec-components/LookupTypeEdit"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { ExpandBtn } from "@/components/Expand"
|
||||
import { getTypeComplexity } from "@/utils/shape"
|
||||
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { Circle, Dot } from "lucide-react"
|
||||
import { FC, useState } from "react"
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
scan,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { addViewFnCall, selectedEntry$ } from "./viewFns.state"
|
||||
|
||||
export const ViewFnQuery: FC = () => {
|
||||
const selectedEntry = useStateObservable(selectedEntry$)
|
||||
const isReady = useStateObservable(isReady$)
|
||||
|
||||
if (!selectedEntry) return null
|
||||
|
||||
const submit = async () => {
|
||||
const [entry, unsafeApi, inputValues, builder] = await firstValueFrom(
|
||||
combineLatest([
|
||||
selectedEntry$,
|
||||
unsafeApi$,
|
||||
inputValues$,
|
||||
dynamicBuilder$,
|
||||
]),
|
||||
)
|
||||
const decodedValues = inputValues.map((v, i) =>
|
||||
builder
|
||||
.buildDefinition(selectedEntry.inputs[i].type)
|
||||
.dec(v as Uint8Array),
|
||||
)
|
||||
const promise = unsafeApi.view[entry!.pallet][entry!.name](...decodedValues)
|
||||
|
||||
addViewFnCall({
|
||||
name: `${entry!.pallet}.${entry!.name}(…)`,
|
||||
promise,
|
||||
type: entry!.output,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<ViewFnInputValues />
|
||||
<ActionButton disabled={!isReady} onClick={submit}>
|
||||
Call
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [inputValueChange$, setInputValue] = createSignal<{
|
||||
idx: number
|
||||
value: Uint8Array | "partial" | null
|
||||
}>()
|
||||
const inputValues$ = selectedEntry$.pipeState(
|
||||
filter((v) => !!v),
|
||||
map((v) => v.inputs),
|
||||
switchMap((inputs) => {
|
||||
const values: Array<Uint8Array | "partial" | null> = inputs.map(() => null)
|
||||
return inputValueChange$.pipe(
|
||||
scan((acc, change) => {
|
||||
const newValue = [...acc]
|
||||
newValue[change.idx] = change.value
|
||||
return newValue
|
||||
}, values),
|
||||
startWith(values),
|
||||
)
|
||||
}),
|
||||
withDefault([] as Array<Uint8Array | "partial" | null>),
|
||||
)
|
||||
|
||||
const isReady$ = inputValues$.pipeState(
|
||||
map((inputValues) => inputValues.every((v) => v instanceof Uint8Array)),
|
||||
withDefault(false),
|
||||
)
|
||||
|
||||
const ViewFnInputValues: FC = () => {
|
||||
const selectedEntry = useStateObservable(selectedEntry$)
|
||||
if (!selectedEntry || !selectedEntry.inputs.length) return null
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
Inputs
|
||||
<ol className="flex flex-col gap-2">
|
||||
{selectedEntry.inputs.map((input, idx) => (
|
||||
<RuntimeValueInput
|
||||
key={idx}
|
||||
idx={idx}
|
||||
name={input.name}
|
||||
type={input.type}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputValue$ = state(
|
||||
(idx: number) => inputValues$.pipe(map((v) => v[idx])),
|
||||
null,
|
||||
)
|
||||
const lookupState$ = state(lookup$, null)
|
||||
const RuntimeValueInput: FC<{ idx: number; name: string; type: number }> = ({
|
||||
idx,
|
||||
type,
|
||||
name,
|
||||
}) => {
|
||||
const value = useStateObservable(inputValue$(idx))
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const lookup = useStateObservable(lookupState$)
|
||||
|
||||
if (!lookup) return null
|
||||
const shape = lookup(type)
|
||||
const complexity = getTypeComplexity(shape)
|
||||
|
||||
return (
|
||||
<li key={idx} className="border rounded p-2 w-full">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center select-none",
|
||||
complexity !== "inline" && "cursor-pointer",
|
||||
)}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
{complexity === "inline" ? (
|
||||
<Dot size={16} />
|
||||
) : (
|
||||
<ExpandBtn expanded={expanded} />
|
||||
)}
|
||||
<Circle
|
||||
size={8}
|
||||
strokeWidth={4}
|
||||
className={twMerge(
|
||||
"mr-1",
|
||||
value === null
|
||||
? "text-red-600"
|
||||
: value === "partial"
|
||||
? "text-orange-600"
|
||||
: "text-green-600",
|
||||
)}
|
||||
/>
|
||||
<div className="text-foreground/80">{name}</div>
|
||||
{complexity === "inline" ? (
|
||||
<div className="px-2">
|
||||
<InlineLookupTypeEdit
|
||||
type={type}
|
||||
value={value}
|
||||
onValueChange={(value) => setInputValue({ idx, value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded && complexity !== "inline" && (
|
||||
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
|
||||
<LookupTypeEdit
|
||||
type={type}
|
||||
value={value}
|
||||
onValueChange={(value) => setInputValue({ idx, value })}
|
||||
tree={complexity === "tree"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ButtonGroup } from "@/components/ButtonGroup"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { FC, useState } from "react"
|
||||
import { ValueDisplay } from "../Storage/StorageSubscriptions"
|
||||
import {
|
||||
removeViewFnResult,
|
||||
ViewFnResult,
|
||||
viewFnResult$,
|
||||
viewFnResultKeys$,
|
||||
} from "./viewFns.state"
|
||||
import { PathsRoot } from "@/codec-components/common/paths.state"
|
||||
|
||||
export const ViewFnResults: FC = () => {
|
||||
const keys = useStateObservable(viewFnResultKeys$)
|
||||
|
||||
if (!keys.length) return null
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full border-t border-border">
|
||||
<h2 className="text-lg text-foreground mb-2">Results</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{keys.map((key) => (
|
||||
<ViewFnResultBox key={key} subscription={key} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
|
||||
const [mode, setMode] = useState<"json" | "decoded">("decoded")
|
||||
const viewFnResult = useStateObservable(viewFnResult$(subscription))
|
||||
if (!viewFnResult) return null
|
||||
|
||||
return (
|
||||
<li className="border rounded bg-card text-card-foreground p-2">
|
||||
<div className="flex justify-between items-center pb-1 overflow-hidden">
|
||||
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{viewFnResult.name}
|
||||
</h3>
|
||||
<div className="flex items-center shrink-0 gap-2">
|
||||
<ButtonGroup
|
||||
value={mode}
|
||||
onValueChange={setMode as any}
|
||||
items={[
|
||||
{
|
||||
value: "decoded",
|
||||
content: "Decoded",
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
content: "JSON",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<button onClick={() => removeViewFnResult(subscription)}>
|
||||
<Trash2
|
||||
size={20}
|
||||
className="text-destructive cursor-pointer hover:text-polkadot-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PathsRoot.Provider value={subscription}>
|
||||
<ResultDisplay viewFnResult={viewFnResult} mode={mode} />
|
||||
</PathsRoot.Provider>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultDisplay: FC<{
|
||||
viewFnResult: ViewFnResult
|
||||
mode: "json" | "decoded"
|
||||
}> = ({ viewFnResult, mode }) => {
|
||||
if ("error" in viewFnResult) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>The call crashed</div>
|
||||
<div>Message: {viewFnResult.error.message ?? "N/A"}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!("result" in viewFnResult)) {
|
||||
return <div className="text-sm text-foreground/50">Loading…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[60svh] overflow-auto">
|
||||
<ValueDisplay
|
||||
mode={mode}
|
||||
type={viewFnResult.type}
|
||||
value={viewFnResult.result}
|
||||
title={"Result"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { lookup$ } from "@/state/chains/chain.state"
|
||||
import { DocsRenderer } from "@/components/DocsRenderer"
|
||||
import { LoadingMetadata } from "@/components/Loading"
|
||||
import { SearchableSelect } from "@/components/Select"
|
||||
import { withSubscribe } from "@/components/withSuspense"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { useEffect, useState } from "react"
|
||||
import { map } from "rxjs"
|
||||
import { ViewFnResults } from "./ViewFnResults"
|
||||
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
|
||||
import { ViewFnQuery } from "./ViewFnQuery"
|
||||
|
||||
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]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
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<string | null>(defaultPallet)
|
||||
const [fnName, setFnName] = useState<string | null>(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 (
|
||||
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<label>
|
||||
Pallet
|
||||
<SearchableSelect
|
||||
value={pallet}
|
||||
setValue={(v) => setPallet(v)}
|
||||
options={Object.keys(entries).map((e) => ({
|
||||
text: e,
|
||||
value: e,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
{selectedPallet && pallet && (
|
||||
<label>
|
||||
Function
|
||||
<SearchableSelect
|
||||
value={fnName}
|
||||
setValue={(v) => setFnName(v)}
|
||||
options={
|
||||
Object.keys(entries[pallet]).map((s) => ({
|
||||
text: s,
|
||||
value: s,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{!!entry?.docs.length && (
|
||||
<div className="w-full">
|
||||
Docs
|
||||
<DocsRenderer docs={entry.docs} />
|
||||
</div>
|
||||
)}
|
||||
<ViewFnQuery />
|
||||
<ViewFnResults />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
fallback: <LoadingMetadata />,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ViewFns"
|
||||
@@ -0,0 +1,79 @@
|
||||
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
|
||||
import { state } from "@react-rxjs/core"
|
||||
import {
|
||||
createKeyedSignal,
|
||||
createSignal,
|
||||
partitionByKey,
|
||||
toKeySet,
|
||||
} from "@react-rxjs/utils"
|
||||
import {
|
||||
catchError,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs"
|
||||
import { v4 as uuid } from "uuid"
|
||||
|
||||
type Pallet = UnifiedMetadata["pallets"][number]
|
||||
export type ViewFnEntry = Pallet["viewFns"][number] & {
|
||||
pallet: string
|
||||
}
|
||||
|
||||
export const [entryChange$, setSelectedFn] = createSignal<ViewFnEntry | null>()
|
||||
export const selectedEntry$ = state(entryChange$, null)
|
||||
|
||||
export const [newViewFnCall$, addViewFnCall] = createSignal<{
|
||||
name: string
|
||||
type: number
|
||||
promise: Promise<unknown>
|
||||
}>()
|
||||
export const [removeViewFnResult$, removeViewFnResult] =
|
||||
createKeyedSignal<string>()
|
||||
|
||||
export type ViewFnResult = {
|
||||
name: string
|
||||
type: number
|
||||
} & ({ result: unknown } | { error?: any })
|
||||
const [getViewFnSubscription$, viewFnKeyChange$] = partitionByKey(
|
||||
newViewFnCall$,
|
||||
() => uuid(),
|
||||
(src$, id) =>
|
||||
src$.pipe(
|
||||
switchMap(
|
||||
({ promise, ...props }): Observable<ViewFnResult> =>
|
||||
from(promise).pipe(
|
||||
map((result) => ({
|
||||
...props,
|
||||
result,
|
||||
paused: false,
|
||||
})),
|
||||
catchError((ex) => {
|
||||
console.error(ex)
|
||||
return of({
|
||||
...props,
|
||||
error: ex,
|
||||
})
|
||||
}),
|
||||
startWith(props),
|
||||
),
|
||||
),
|
||||
takeUntil(removeViewFnResult$(id)),
|
||||
),
|
||||
)
|
||||
|
||||
export const viewFnResultKeys$ = state(
|
||||
viewFnKeyChange$.pipe(
|
||||
toKeySet(),
|
||||
map((keys) => [...keys].reverse()),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export const viewFnResult$ = state(
|
||||
(key: string): Observable<ViewFnResult> => getViewFnSubscription$(key),
|
||||
null,
|
||||
)
|
||||
Reference in New Issue
Block a user