diff --git a/src/App.tsx b/src/App.tsx index cb94759..0538fef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx index 90ce6f0..f851163 100644 --- a/src/pages/Header.tsx +++ b/src/pages/Header.tsx @@ -82,6 +82,9 @@ export const Header = () => { ))} + setIsOpen(false)}> + View Functions + setIsOpen(false)}> Metadata diff --git a/src/pages/Metadata/Pallets.tsx b/src/pages/Metadata/Pallets.tsx index 79cbdab..2b7d2f0 100644 --- a/src/pages/Metadata/Pallets.tsx +++ b/src/pages/Metadata/Pallets.tsx @@ -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 }> = ({ pallets }) => { @@ -22,7 +22,7 @@ export const Pallets: FC<{ pallets: Array }> = ({ pallets }) => { /> {pallet && ( - <> + Index: {pallet.index} {pallet.storage && ( @@ -36,6 +36,12 @@ export const Pallets: FC<{ pallets: Array }> = ({ pallets }) => { )} + {pallet.viewFns.length > 0 && ( + + View Functions + + + )} {pallet.calls != null && ( Calls @@ -54,7 +60,7 @@ export const Pallets: FC<{ pallets: Array }> = ({ pallets }) => { )} - > + )} ) @@ -68,10 +74,6 @@ type StorageEntry = Pallet["storage"] extends const PalletStorage: FC<{ pallet: Pallet }> = ({ pallet }) => { const [entry, setEntry] = useState(null) - useEffect(() => { - setEntry(null) - }, [pallet]) - if (!pallet.storage) return null const value = @@ -129,10 +131,6 @@ type ConstantEntry = Pallet["constants"] extends Array ? R : never const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => { const [entry, setEntry] = useState(null) - useEffect(() => { - setEntry(null) - }, [pallet]) - if (!pallet.constants.length) return null return ( @@ -166,3 +164,42 @@ const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => { ) } + +type ViewFnEntry = Pallet["viewFns"] extends Array ? R : never +const PalletViewFns: FC<{ pallet: Pallet }> = ({ pallet }) => { + const [entry, setEntry] = useState(null) + + if (!pallet.viewFns.length) return null + + return ( + + ({ + text: c.name, + value: c, + }))} + /> + {entry && ( + <> + + Inputs + + {entry.inputs.map((input) => ( + + {input.name} + + + ))} + + + + Output + + + > + )} + + ) +} diff --git a/src/pages/ViewFns/ViewFnQuery.tsx b/src/pages/ViewFns/ViewFnQuery.tsx new file mode 100644 index 0000000..5331ecb --- /dev/null +++ b/src/pages/ViewFns/ViewFnQuery.tsx @@ -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 ( + + + + Call + + + ) +} + +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 = 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), +) + +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 ( + + Inputs + + {selectedEntry.inputs.map((input, idx) => ( + + ))} + + + ) +} + +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 ( + + setExpanded((e) => !e)} + > + {complexity === "inline" ? ( + + ) : ( + + )} + + {name} + {complexity === "inline" ? ( + + setInputValue({ idx, value })} + /> + + ) : null} + + {expanded && complexity !== "inline" && ( + + setInputValue({ idx, value })} + tree={complexity === "tree"} + /> + + )} + + ) +} diff --git a/src/pages/ViewFns/ViewFnResults.tsx b/src/pages/ViewFns/ViewFnResults.tsx new file mode 100644 index 0000000..90334cd --- /dev/null +++ b/src/pages/ViewFns/ViewFnResults.tsx @@ -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 ( + + Results + + {keys.map((key) => ( + + ))} + + + ) +} + +const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => { + const [mode, setMode] = useState<"json" | "decoded">("decoded") + const viewFnResult = useStateObservable(viewFnResult$(subscription)) + if (!viewFnResult) return null + + return ( + + + + {viewFnResult.name} + + + + removeViewFnResult(subscription)}> + + + + + + + + + ) +} + +const ResultDisplay: FC<{ + viewFnResult: ViewFnResult + mode: "json" | "decoded" +}> = ({ viewFnResult, mode }) => { + if ("error" in viewFnResult) { + return ( + + The call crashed + Message: {viewFnResult.error.message ?? "N/A"} + + ) + } + + if (!("result" in viewFnResult)) { + return Loading… + } + + return ( + + + + ) +} diff --git a/src/pages/ViewFns/ViewFns.tsx b/src/pages/ViewFns/ViewFns.tsx new file mode 100644 index 0000000..a815efd --- /dev/null +++ b/src/pages/ViewFns/ViewFns.tsx @@ -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(defaultPallet) + const [fnName, setFnName] = useState(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 ( + + + + Pallet + setPallet(v)} + options={Object.keys(entries).map((e) => ({ + text: e, + value: e, + }))} + /> + + {selectedPallet && pallet && ( + + Function + setFnName(v)} + options={ + Object.keys(entries[pallet]).map((s) => ({ + text: s, + value: s, + })) ?? [] + } + /> + + )} + + {!!entry?.docs.length && ( + + Docs + + + )} + + + + ) + }, + { + fallback: , + }, +) diff --git a/src/pages/ViewFns/index.ts b/src/pages/ViewFns/index.ts new file mode 100644 index 0000000..7b30922 --- /dev/null +++ b/src/pages/ViewFns/index.ts @@ -0,0 +1 @@ +export * from "./ViewFns" diff --git a/src/pages/ViewFns/viewFns.state.ts b/src/pages/ViewFns/viewFns.state.ts new file mode 100644 index 0000000..271f579 --- /dev/null +++ b/src/pages/ViewFns/viewFns.state.ts @@ -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() +export const selectedEntry$ = state(entryChange$, null) + +export const [newViewFnCall$, addViewFnCall] = createSignal<{ + name: string + type: number + promise: Promise +}>() +export const [removeViewFnResult$, removeViewFnResult] = + createKeyedSignal() + +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 => + 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 => getViewFnSubscription$(key), + null, +)
Index: {pallet.index}