feat: extrinsics submit panel (#147)

* relayout extrinsics

* feat: extrinsics submit panel

* restyle submit, scroll to submit on small screens

* disable sign&submit buttons when no signer is selected
This commit is contained in:
Victor Oliva
2026-05-27 10:44:28 +02:00
committed by GitHub
parent 18f10af8bf
commit 31b6db3e60
22 changed files with 813 additions and 417 deletions
+5 -5
View File
@@ -18,14 +18,14 @@ export default function App() {
<AppShell>
<Routes>
<Route path="explorer/*" element={<Explorer />} />
<Route path="extrinsics/*" element={<Extrinsics />} />
<Route path="storage/*" element={<Storage />} />
<Route path="constants/*" element={<Constants />} />
<Route path="extrinsics/*" element={<Extrinsics />} />
<Route path="runtimeCalls/*" element={<RuntimeCalls />} />
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="metadata/*" element={<Metadata />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="viewFns/*" element={<ViewFns />} />
<Route path="constants/*" element={<Constants />} />
<Route path="metadata/*" element={<Metadata />} />
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</AppShell>
@@ -12,14 +12,23 @@ export const FocusPath: FC<{
onFocus: (value: string[] | null) => void
collapsed?: boolean
onToggleCollapse?: () => void
}> = ({ metadata, typeId, value, onFocus, collapsed, onToggleCollapse }) => {
collapsibleBreak?: `${string}:hidden`
}> = ({
metadata,
typeId,
value,
onFocus,
collapsed,
collapsibleBreak = "max-sm:hidden",
onToggleCollapse,
}) => {
const breadcrumbs = getBreadcrumbs(metadata, typeId, value ?? [], onFocus)
return (
<div className="shrink-0 px-2">
<div className="flex max-w-full flex-wrap gap-1 items-center border border-border text-sm px-2 py-1">
{onToggleCollapse ? (
<button onClick={onToggleCollapse} className="max-sm:hidden">
<button onClick={onToggleCollapse} className={collapsibleBreak}>
{collapsed ? (
<ArrowRightToLine size={16} />
) : (
+3 -2
View File
@@ -1,11 +1,12 @@
import { CenteredScrollContainer } from "../AppShell"
import { AccountList } from "./AccountList"
import { Providers } from "./Providers"
export const Accounts = () => {
return (
<div className="p-4 space-y-4">
<CenteredScrollContainer className="p-4 space-y-4">
<Providers />
<AccountList />
</div>
</CenteredScrollContainer>
)
}
+16 -10
View File
@@ -69,21 +69,27 @@ const ManageToDialog: FC<PropsWithChildren> = ({ children }) => {
)
}
export const PolkahubModalBasedManagers = () => (
<>
<ul className="flex gap-2 flex-wrap items-center justify-center">
<ManageLedger />
<ManageVault />
<WalletConnectButton />
</ul>
<ul className="flex gap-2 flex-wrap items-center justify-center">
<ManageReadOnly />
<ManageProxy />
<ManageMultisig />
</ul>
</>
)
export const Providers = () => (
<div className="bg-card p-4 rounded-xl space-y-2">
<h3 className="text-xl font-bold">Account Providers</h3>
<PjsWalletButtons />
<ManageToDialog>
<ul className="flex gap-2 flex-wrap items-center justify-center">
<ManageLedger />
<ManageVault />
<WalletConnectButton />
</ul>
<ul className="flex gap-2 flex-wrap items-center justify-center">
<ManageReadOnly />
<ManageProxy />
<ManageMultisig />
</ul>
<PolkahubModalBasedManagers />
</ManageToDialog>
</div>
)
+19 -5
View File
@@ -26,6 +26,7 @@ import { FC, PropsWithChildren, useState } from "react"
import { useLocation } from "react-router-dom"
import { twMerge } from "tailwind-merge"
import { NetworkSwitcher } from "./Network/Network"
import { cn } from "@/lib/utils"
const navigationGroups: Array<{
label: string
@@ -111,17 +112,30 @@ export const AppShell: FC<PropsWithChildren> = ({ children }) => {
</Sheet>
<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) @container">
{children}
</div>
</div>
{children}
</div>
<HistoryDrawer />
</div>
)
}
export const CenteredScrollContainer: FC<
PropsWithChildren<{
className?: string
}>
> = ({ className, children }) => (
<div className="relative min-h-0 flex-1 overflow-auto">
<div
className={cn(
"mx-auto h-full w-full max-w-(--breakpoint-xl) @container",
className,
)}
>
{children}
</div>
</div>
)
const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({
mobile,
onNavigate,
+1 -1
View File
@@ -43,7 +43,7 @@ export const Constants = withSubscribe(
const entries = useStateObservable(metadataConstants$)
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start leading-relaxed">
<div className="p-4 pb-0 flex flex-col gap-2 items-start leading-relaxed overflow-auto">
<ul>
{entries.map(({ name, constants }) => (
<PalletConstants key={name} pallet={name} entries={constants} />
+17 -14
View File
@@ -1,6 +1,7 @@
import { LoadingBlocks } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
import { Route, Routes } from "react-router-dom"
import { CenteredScrollContainer } from "../AppShell"
import { BlockTable } from "./BlockTable"
import { BlockDetail } from "./Detail"
import { Events } from "./Events"
@@ -8,21 +9,23 @@ import { Summary } from "./Summary"
export const Explorer = withSubscribe(
() => (
<Routes>
<Route path=":hashOrHeight" element={<BlockDetail />} />
<Route
path="*"
element={
<div className="p-4 pb-0 max-w-(--breakpoint-lg) m-auto">
<Summary />
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
<BlockTable />
<Events />
<CenteredScrollContainer>
<Routes>
<Route path=":hashOrHeight" element={<BlockDetail />} />
<Route
path="*"
element={
<div className="p-4 pb-0 max-w-(--breakpoint-lg) m-auto">
<Summary />
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
<BlockTable />
<Events />
</div>
</div>
</div>
}
/>
</Routes>
}
/>
</Routes>
</CenteredScrollContainer>
),
{
fallback: <LoadingBlocks />,
@@ -12,7 +12,7 @@ export const ExtrinsicAnalyzer: FC = () => {
)
return (
<div className="space-y-4 p-3 md:p-4">
<div className="space-y-4 p-3 md:p-4 overflow-auto">
<header className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
Analyze Extrinsic
+120
View File
@@ -0,0 +1,120 @@
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
import { ButtonGroup } from "@/components/ButtonGroup"
import { useSyncHashParam } from "@/hashParams"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { CodecComponentType } from "@polkadot-api/react-builder"
import { state, useStateObservable } from "@react-rxjs/core"
import { fromHex, toHex } from "polkadot-api/utils"
import { FC, useRef, useState } from "react"
import { map } from "rxjs"
import { EditMode } from "./EditMode"
import { JsonMode } from "./JsonMode"
import { SubmitExtrinsic } from "./SubmitTx/SubmitExtrinsic"
import {
codecComponentValue$,
getBinaryValue,
setComponentValue,
} from "./componentValue.state"
import { ActionButton } from "@/components/ActionButton"
const extrinsicProps$ = state(
runtimeCtx$.pipe(
map(({ dynamicBuilder, lookup }) => {
const codecType =
"call" in lookup.metadata.extrinsic
? lookup.metadata.extrinsic.call
: // TODO v14 is this one?
lookup.metadata.extrinsic.type
return {
metadata: lookup.metadata,
codecType,
codec: dynamicBuilder.buildDefinition(codecType),
}
}),
),
)
export const CreateExtrinsic: FC = () => {
const submitRef = useRef<HTMLElement>(null)
return (
<div className="border border-accent overflow-hidden @max-6xl:overflow-auto @max-6xl:space-y-2 @4xl:flex-1 @4xl:flex @4xl:gap-2">
<ExtrinsicEditor
onScrollToSubmit={() =>
submitRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
/>
<SubmitExtrinsic ref={submitRef} />
</div>
)
}
const ExtrinsicEditor: FC<{ onScrollToSubmit: () => void }> = ({
onScrollToSubmit,
}) => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const componentValue = useStateObservable(codecComponentValue$)
const binaryValue = getBinaryValue(componentValue)
useSyncHashParam("data", [binaryValue], (v) =>
v && v.length < 1024 * 1024 ? toHex(v) : null,
)
const extrinsicProps = useStateObservable(extrinsicProps$)
return (
<div className="flex flex-col overflow-hidden gap-2 pt-2 w-full @6xl:h-full">
<BinaryDisplay
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
setComponentValue({ type: CodecComponentType.Updated, value })
}
/>
<div className="flex flex-row justify-between px-2">
<ButtonGroup
value={viewMode}
onValueChange={setViewMode as any}
items={[
{
value: "edit",
content: "Edit",
},
{
value: "json",
content: "JSON",
disabled: !binaryValue,
},
]}
/>
<div className="flex flex-row items-center gap-2 @4xl:hidden">
<ActionButton onClick={onScrollToSubmit}>
Scroll to submit
</ActionButton>
</div>
</div>
{viewMode === "edit" ? (
<EditMode
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
setComponentValue({ type: CodecComponentType.Updated, value })
}
treeViewBreak="@max-6xl:hidden"
/>
) : (
<JsonMode
value={
typeof binaryValue === "string" ? fromHex(binaryValue) : binaryValue
}
decode={extrinsicProps.codec.dec}
/>
)}
</div>
)
}
+10 -24
View File
@@ -6,39 +6,25 @@ import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { lookup$, metadata$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils"
import { Button } from "@polkahub/ui-components"
import { state, Subscribe, useStateObservable } from "@react-rxjs/core"
import { combineKeys, createSignal, mergeWithKey } from "@react-rxjs/utils"
import { ChevronLeft, Circle } from "lucide-react"
import { Circle } from "lucide-react"
import { FC, useState } from "react"
import { combineLatest, defer, map, merge, scan, startWith } from "rxjs"
import { twMerge } from "tailwind-merge"
export const CustomSignedExt: FC<{ onClose: () => void }> = ({ onClose }) => {
export const CustomSignedExt: FC = () => {
const metadata = useStateObservable(metadata$)
return (
<div className="p-2 space-y-2">
<div className="flex items-center gap-2">
<Button
className="has-[>svg]:px-1"
type="button"
variant="ghost"
onClick={onClose}
>
<ChevronLeft />
</Button>
<h2 className="text-lg font-bold">Custom Signed Extensions</h2>
</div>
<Subscribe source$={extSub$}>
<ul className="space-y-2">
{/* TODO: adapt for different extrinsic versions */}
{metadata.extrinsic.signedExtensions[0].map((ext) => (
<ExtensionInput key={ext.identifier} {...ext} />
))}
</ul>
</Subscribe>
</div>
<Subscribe source$={extSub$}>
<ul className="space-y-2">
{/* TODO: adapt for different extrinsic versions */}
{metadata.extrinsic.signedExtensions[0].map((ext) => (
<ExtensionInput key={ext.identifier} {...ext} />
))}
</ul>
</Subscribe>
)
}
+5 -2
View File
@@ -21,8 +21,9 @@ export const EditMode: React.FC<{
metadata: UnifiedMetadata
value: CodecComponentValue
onUpdate: (value: CodecComponentUpdate) => void
treeViewBreak?: `${string}:hidden`
}> = (props) => {
const { metadata, codecType, value } = props
const { metadata, codecType, value, treeViewBreak = "max-sm:hidden" } = props
const treeRef = useRef<HTMLDivElement | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
@@ -43,6 +44,7 @@ export const EditMode: React.FC<{
onFocus={setFocusingSubtree}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((c) => !c)}
collapsibleBreak={treeViewBreak}
/>
<SubtreeFocus
value={{ callback: setFocusingSubtree, path: focusingSubtree }}
@@ -55,7 +57,8 @@ export const EditMode: React.FC<{
<div
ref={treeRef}
className={cn(
"min-w-fit w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden max-sm:hidden text-sm",
"min-w-fit w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden text-sm",
treeViewBreak,
{
hidden: collapsed,
},
+25 -165
View File
@@ -1,182 +1,42 @@
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
import { actionButtonClassName } from "@/components/ActionButton"
import { ButtonGroup } from "@/components/ButtonGroup"
import { IconActionButton, IconLink } from "@/components/IconButton"
import { LoadingMetadata } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
import { getHashParams, useSyncHashParam } from "@/hashParams"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { cn } from "@/utils"
import {
CodecComponentType,
CodecComponentValue,
} from "@polkadot-api/react-builder"
import { state, useStateObservable } from "@react-rxjs/core"
import { FileSearch, Settings } from "lucide-react"
import { fromHex, toHex } from "polkadot-api/utils"
import { FC, useState } from "react"
import { Route, Routes, useLocation } from "react-router-dom"
import { map } from "rxjs"
import { twMerge } from "tailwind-merge"
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
import { EditMode } from "./EditMode"
import { useNavigate } from "@/hashParams"
import { Route, Routes, useMatch } from "react-router-dom"
import { ExtrinsicAnalyzer } from "./Analyzer/ExtrinsicAnalyzer"
import { JsonMode } from "./JsonMode"
import { ExtrinsicModal } from "./SubmitTx/SubmitTx"
const extrinsicProps$ = state(
runtimeCtx$.pipe(
map(({ dynamicBuilder, lookup }) => {
const codecType =
"call" in lookup.metadata.extrinsic
? lookup.metadata.extrinsic.call
: // TODO v14 is this one?
lookup.metadata.extrinsic.type
return {
metadata: lookup.metadata,
codecType,
codec: dynamicBuilder.buildDefinition(codecType),
}
}),
),
)
const customExtensionsCount$ = state(
customSignedExtensions$.pipe(
map((v) => Object.keys(v).length),
map((v) =>
v ? (
<div className="px-1.5 rounded-full bg-chart-1 text-white text-sm">
{v}
</div>
) : null,
),
),
null,
)
import { CreateExtrinsic } from "./CreateExtrinsic"
export const Extrinsics = withSubscribe(
() => {
const location = useLocation()
const [componentValue, setComponentValue] = useState<CodecComponentValue>({
type: CodecComponentType.Initial,
value: getHashParams(location).get("data") ?? "",
})
const binaryValue =
(componentValue.type === CodecComponentType.Initial
? typeof componentValue.value === "string"
? fromHex(componentValue.value)
: componentValue.value
: componentValue.value.empty
? null
: componentValue.value.encoded) ?? null
useSyncHashParam("data", [binaryValue], (v) =>
v && v.length < 1024 * 1024 ? toHex(v) : null,
)
const isAnalyzer = !!useMatch("/extrinsics/analyzer")
const navigate = useNavigate()
return (
<Routes>
<Route path="analyzer" element={<ExtrinsicAnalyzer />} />
<Route
path="*"
element={
<ExtrinsicEditor
componentValue={componentValue}
onValueChange={setComponentValue}
binaryValue={binaryValue}
/>
<div className="flex-1 overflow-hidden p-2 flex flex-col @container">
<ButtonGroup
value={isAnalyzer ? "analyze" : "create"}
onValueChange={(mode) =>
navigate(`/extrinsics` + (mode === "analyze" ? `/analyzer` : ""))
}
items={[
{
value: "create",
content: "Create",
},
{
value: "analyze",
content: "Analyze",
},
]}
/>
</Routes>
<Routes>
<Route path="analyzer" element={<ExtrinsicAnalyzer />} />
<Route path="*" element={<CreateExtrinsic />} />
</Routes>
</div>
)
},
{
fallback: <LoadingMetadata />,
},
)
const ExtrinsicEditor: FC<{
componentValue: CodecComponentValue
onValueChange: (value: CodecComponentValue) => void
binaryValue: Uint8Array | null
}> = ({ componentValue, onValueChange, binaryValue }) => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const [page, setPage] = useState<"extensions" | null>(null)
const extrinsicProps = useStateObservable(extrinsicProps$)
if (page === "extensions")
return <CustomSignedExt onClose={() => setPage(null)} />
return (
<div
className={twMerge(
"flex flex-col overflow-hidden gap-2 p-4 pb-0",
// Bypassing top-level scroll area, since we need a specific scroll area for the tree view
"absolute w-full h-full max-w-(--breakpoint-xl)",
)}
>
<BinaryDisplay
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
onValueChange({ type: CodecComponentType.Updated, value })
}
/>
<div className="flex flex-row justify-between px-2">
<ButtonGroup
value={viewMode}
onValueChange={setViewMode as any}
items={[
{
value: "edit",
content: "Edit",
},
{
value: "json",
content: "JSON",
disabled: !binaryValue,
},
]}
/>
<div className="flex flex-row items-center gap-2">
<IconLink
tooltip="Analyze extrinsic"
to="/extrinsics/analyzer"
className={cn(actionButtonClassName(), "text-foreground/70")}
>
<FileSearch />
</IconLink>
<IconActionButton
tooltip="Custom signed extensions"
className="text-foreground/70 flex items-center gap-1"
onClick={() => setPage("extensions")}
>
{customExtensionsCount$}
<Settings />
</IconActionButton>
<ExtrinsicModal callData={binaryValue ?? undefined} />
</div>
</div>
{viewMode === "edit" ? (
<EditMode
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
onValueChange({ type: CodecComponentType.Updated, value })
}
/>
) : (
<JsonMode
value={
typeof binaryValue === "string" ? fromHex(binaryValue) : binaryValue
}
decode={extrinsicProps.codec.dec}
/>
)}
</div>
)
}
@@ -1,31 +0,0 @@
import { Label } from "@/components/ui/label"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
availableExtensions$,
selectedExtensions$,
toggleExtension,
} from "@/state/polkahub"
import { useStateObservable } from "@react-rxjs/core"
export const ExtensionProvider: React.FC = () => {
const availableExtensions = useStateObservable(availableExtensions$)
const selectedExtensions = new Set(useStateObservable(selectedExtensions$))
return (
<>
<Label>Click on the provider name to toggle it:</Label>
<TabsList>
{availableExtensions.map((extensionName) => (
<TabsTrigger
className="mx-1"
onClick={() => toggleExtension(extensionName)}
active={selectedExtensions.has(extensionName)}
key={extensionName}
>
{extensionName}
</TabsTrigger>
))}
</TabsList>
</>
)
}
@@ -0,0 +1,499 @@
import { ActionButton } from "@/components/ActionButton"
import { Spinner } from "@/components/Icons"
import { TokenAmount } from "@/components/TokenAmount"
import { Dialog, DialogTrigger } from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { createState } from "@/lib/externalState"
import { PolkahubModalBasedManagers } from "@/pages/Accounts/Providers"
import { client$, unsafeApi$ } from "@/state/chains/chain.state"
import { selectedAccount$ } from "@/state/polkahub"
import { polkadot_people } from "@polkadot-api/descriptors"
import {
Button,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
Input,
} from "@polkahub/ui-components"
import {
liftSuspense,
state,
SUSPENSE,
useStateObservable,
} from "@react-rxjs/core"
import {
createSignal,
mergeWithKey,
switchMapSuspended,
} from "@react-rxjs/utils"
import { ChevronLeft, Send, Settings, WalletCards } from "lucide-react"
import { AccountId, TxOptions } from "polkadot-api"
import {
ModalContext,
PjsWalletButtons,
usePolkaHubModalState,
useSelectedAccount,
} from "polkahub"
import { FC, forwardRef, ReactNode, useState } from "react"
import {
catchError,
combineLatest,
defer,
map,
scan,
switchMap,
timer,
} from "rxjs"
import { callData$ } from "../componentValue.state"
import { CustomSignedExt, customSignedExtensions$ } from "../CustomSignedExt"
import { trackTx } from "../ExtrinsicsWorkspaceEntry"
import { SelectAccount } from "./SubmitTxForm"
const customExtensionsCount$ = state(
customSignedExtensions$.pipe(
map((v) => Object.keys(v).length),
map((v) =>
v ? (
<div className="px-1.5 rounded-full bg-chart-1 text-white text-sm">
{v}
</div>
) : null,
),
),
null,
)
const [nonceChanged$, setNonce] = createSignal<string>()
const [nonceBlurred$, blurNonce] = createSignal()
const chainNonce$ = unsafeApi$.pipe(
switchMapSuspended((api) =>
selectedAccount$.pipe(
switchMapSuspended((account) =>
account?.signer
? api.apis.AccountNonceApi.account_nonce(
AccountId(42).dec(account.signer.publicKey),
{
at: "best",
},
)
: [],
),
liftSuspense(),
catchError((ex) => {
console.error(ex)
return []
}),
),
),
liftSuspense(),
map((v) => (v === SUSPENSE ? null : (v as number))),
)
const isIntegerStr = (str: string) => /^\d+$/.test(str)
const nonce$ = state(
defer(() =>
mergeWithKey({
chainNonce$,
nonceChanged$,
nonceBlurred$,
}).pipe(
scan(
(
acc: {
chain: number | null
inputValue: string
},
v,
) => {
switch (v.type) {
case "chainNonce$":
if (v.payload != null)
return { chain: v.payload, inputValue: v.payload.toString() }
break
case "nonceBlurred$":
if (!isIntegerStr(acc.inputValue)) {
return {
chain: acc.chain,
inputValue: acc.chain?.toString() ?? "",
}
}
break
case "nonceChanged$":
return { chain: acc.chain, inputValue: v.payload }
}
return acc
},
{ chain: null, inputValue: "" },
),
map((v) => v.inputValue),
),
),
"",
)
type Mortality = NonNullable<TxOptions<any, any>["mortality"]>
const DEFAULT_MORTAL = {
mortal: true,
period: 64,
}
// powers of 2 from 4 to 16 (incl)
const periodOptions = new Array(16 - 4 + 1).fill(0).map((_, i) => 1 << (i + 4))
const [mortality$, setMortality] = createState<Mortality>(DEFAULT_MORTAL)
const [tip$, setTip] = createState("0")
const transaction$ = state(
combineLatest([unsafeApi$, callData$]).pipe(
switchMapSuspended(([unsafeApi, callData]) =>
callData ? unsafeApi.txFromCallData(callData) : [null],
),
liftSuspense(),
map((v) => (v === SUSPENSE ? null : v)),
),
null,
)
const txOptions$ = state(
combineLatest([
nonce$.pipe(map((v) => (isIntegerStr(v) ? Number(v) : null))),
mortality$,
tip$.pipe(map((v) => (isIntegerStr(v) ? BigInt(v) : null))),
customSignedExtensions$,
]).pipe(
map(([nonce, mortality, tip, signedExt]): TxOptions<any, any> => {
return {
mortality,
nonce: nonce ?? undefined,
tip: tip ?? undefined,
customSignedExtensions: signedExt,
}
}),
),
{} satisfies TxOptions<any, any>,
)
const paymentInfo$ = state(
combineLatest([transaction$, selectedAccount$, txOptions$]).pipe(
switchMapSuspended(([tx, account, txOptions]) => {
if (!tx || !account?.signer) return [null]
// Adding a small delay for debouncing quick input changes
return timer(200).pipe(
switchMap(() =>
tx.getPaymentInfo(account.signer!.publicKey, txOptions),
),
)
}),
liftSuspense(),
map((v) => (v === SUSPENSE ? null : v)),
),
null,
)
const accountBalance$ = state(
combineLatest([selectedAccount$, client$]).pipe(
switchMapSuspended(([account, client]) =>
account?.signer
? client
.getTypedApi(polkadot_people)
.query.System.Account.getValue(
AccountId().dec(account.signer.publicKey),
)
: [],
),
liftSuspense(),
map((v) => (v === SUSPENSE ? null : v)),
map((v) => {
if (!v) return null
const { reserved, free, frozen } = v.data
const total = reserved + free
// TODO ED
const untouchable = total == 0n ? 0n : maxBigInt(frozen - reserved, 0n)
return free - untouchable
}),
),
null,
)
export const SubmitExtrinsic = forwardRef<HTMLElement>((_, ref) => {
const [account] = useSelectedAccount()
const nonce = useStateObservable(nonce$)
const mortality = useStateObservable(mortality$)
const tip = useStateObservable(tip$)
const paymentInfo = useStateObservable(paymentInfo$)
const balance = useStateObservable(accountBalance$)
const tx = useStateObservable(transaction$)
const txOptions = useStateObservable(txOptions$)
const [isSigning, setIsSigning] = useState(false)
const signAndSubmit = async () => {
if (!account?.signer || !tx) return
setIsSigning(true)
try {
const signedExtrinsic = await tx.sign(account.signer, txOptions)
trackTx(signedExtrinsic, tx.decodedCall, account)
} catch (ex) {
console.error(ex)
}
setIsSigning(false)
}
const submitUnsigned = async () => {
if (!tx) return
setIsSigning(true)
try {
const extrinsic = await tx.getBareTx()
trackTx(extrinsic, tx.decodedCall)
} catch (ex) {
console.error(ex)
}
setIsSigning(false)
}
return (
<aside
ref={ref}
className="mx-auto max-w-md overflow-auto rounded-lg border border-border bg-background shadow-sm @4xl:mx-0 @4xl:w-full @4xl:rounded-none @4xl:border-y-0 @4xl:border-r-0 @4xl:shadow-none"
>
<div className="px-4 py-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Submit
</h2>
</div>
<section className="mx-4 space-y-3 border-t border-border py-4">
<SubmitRow label="Signer">
<div className="flex gap-2 items-center">
<SelectAccount />
<SignerSetupDialog />
</div>
</SubmitRow>
<SubmitRow label="Nonce">
<Input
type="number"
min={0}
value={nonce}
onChange={(evt) => setNonce(evt.target.value)}
onBlur={blurNonce}
className="tabular-nums"
/>
</SubmitRow>
<SubmitRow label="Mortality">
<div className="flex-1 flex items-center rounded focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<Select
value={mortality.mortal ? "mortal" : "immortal"}
onValueChange={(v) =>
setMortality(
v === "mortal" ? DEFAULT_MORTAL : { mortal: false },
)
}
>
<SelectTrigger className="flex-2 focus:ring-0 not-last:rounded-r-none not-last:border-r-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mortal">Mortal</SelectItem>
<SelectItem value="immortal">Immortal</SelectItem>
</SelectContent>
</Select>
{mortality.mortal ? (
<Select
value={mortality.period.toString()}
onValueChange={(v) =>
setMortality({ mortal: true, period: Number(v) })
}
>
<SelectTrigger className="flex-1 focus:ring-0 rounded-l-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periodOptions.map((period) => (
<SelectItem key={period} value={period.toString()}>
{period} blocks
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
</SubmitRow>
<SubmitRow label="Tip">
<div className="flex-1 flex items-center gap-2">
<Input
type="number"
min={0}
value={tip}
onChange={(evt) => setTip(evt.target.value)}
className="font-mono"
/>
<p>Planck</p>
</div>
</SubmitRow>
<CustomSignedExtDialog />
</section>
<section className="mx-4 space-y-3 border-t border-border py-4">
<h3 className="text-sm font-medium">Fees</h3>
<FeeRow
label="Estimated fee"
value={
paymentInfo ? (
<TokenAmount>{paymentInfo.partial_fee}</TokenAmount>
) : (
"…"
)
}
/>
<FeeRow
label="Account spendable balance"
value={balance == null ? "…" : <TokenAmount>{balance}</TokenAmount>}
/>
</section>
<section className="mx-4 space-y-3 border-t border-border py-4">
<ActionButton
className="flex w-full items-center justify-center gap-2 rounded-md py-2.5 text-sm font-semibold"
disabled={!tx || !account?.signer || isSigning}
onClick={signAndSubmit}
>
<Send className="h-4 w-4" />
Sign & Submit
{isSigning && <Spinner size={16} />}
</ActionButton>
<ActionButton
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-background py-2.5 text-sm font-semibold text-foreground hover:bg-foreground/5"
onClick={submitUnsigned}
disabled={!tx || isSigning}
>
Submit without signing
</ActionButton>
</section>
</aside>
)
})
const SignerSetupDialog: FC = () => {
const [open, setOpen] = useState(false)
const { contentStack, contextValue } = usePolkaHubModalState(() =>
setOpen(false),
)
const activeContent = contentStack.length
? contentStack[contentStack.length - 1]
: null
return (
<Dialog
open={open}
onOpenChange={(open) =>
open ? setOpen(true) : contextValue.closeModal()
}
>
<DialogTrigger asChild>
<button
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background hover:bg-foreground/5"
type="button"
aria-label="Configure signers"
>
<WalletCards className="h-4 w-4" />
</button>
</DialogTrigger>
<DialogContent
onInteractOutside={(evt) => {
if (
evt.target instanceof HTMLElement &&
evt.target.tagName === "WCM-MODAL"
)
evt.preventDefault()
}}
>
<DialogHeader className="flex-row items-center">
{contentStack.length ? (
<Button
className="has-[>svg]:px-1"
type="button"
variant="ghost"
onClick={() => contextValue.popContent()}
>
<ChevronLeft />
</Button>
) : null}
<DialogTitle>{activeContent?.title ?? "Signers"}</DialogTitle>
</DialogHeader>
<DialogBody className="space-y-3">
<ModalContext value={contextValue}>
{activeContent ? (
activeContent.element
) : (
<div className="space-y-4">
<SelectAccount />
<PjsWalletButtons />
<PolkahubModalBasedManagers />
</div>
)}
</ModalContext>
</DialogBody>
</DialogContent>
</Dialog>
)
}
const CustomSignedExtDialog: FC = () => {
const customExtensionsCount = useStateObservable(customExtensionsCount$)
return (
<Dialog>
<DialogTrigger asChild>
<button
className="flex w-full items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-left text-sm hover:bg-foreground/5"
type="button"
>
<Settings className="h-4 w-4" />
Custom extensions
{customExtensionsCount ? (
<span className="ml-auto">{customExtensionsCount}</span>
) : null}
</button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Custom Signed Extensions</DialogTitle>
</DialogHeader>
<DialogBody className="space-y-3">
<CustomSignedExt />
</DialogBody>
</DialogContent>
</Dialog>
)
}
const SubmitRow: FC<{
label: string
children: ReactNode
}> = ({ label, children }) => (
<div className={`grid grid-cols-[4.75rem_minmax(0,1fr)] gap-3 items-center`}>
<div className="text-sm font-medium text-muted-foreground">{label}</div>
{children}
</div>
)
const FeeRow: FC<{ label: string; value: ReactNode }> = ({ label, value }) => (
<div className="flex items-center justify-between gap-3 py-1.5">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-right font-mono text-sm">{value}</span>
</div>
)
const maxBigInt = (a: bigint, b: bigint) => (a > b ? a : b)
@@ -1,53 +0,0 @@
import { ActionButton } from "@/components/ActionButton"
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { toHex } from "polkadot-api/utils"
import { HexString } from "polkadot-api"
import React, { lazy, Suspense, useState } from "react"
const SendTransactionForm = lazy(() => import("./SubmitTxForm"))
export const ExtrinsicModal: React.FC<{
callData: Uint8Array | HexString | undefined
}> = ({ callData }) => {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ActionButton disabled={callData == null || callData.length === 0}>
Submit extrinsic
</ActionButton>
</DialogTrigger>
<DialogContent
onInteractOutside={(evt) => {
if (
evt.target instanceof HTMLElement &&
evt.target.tagName === "WCM-MODAL"
)
evt.preventDefault()
}}
className="flex flex-col overflow-hidden max-w-lg sm:max-w-lg md:max-w-lg"
>
<DialogHeader>
<DialogTitle>Create TX</DialogTitle>
</DialogHeader>
<DialogBody className="flex flex-col gap-2">
<Suspense fallback="Loading…">
<SendTransactionForm
callData={
callData instanceof Uint8Array ? toHex(callData) : callData!
}
onClose={() => setOpen(false)}
/>
</Suspense>
</DialogBody>
</DialogContent>
</Dialog>
)
}
+8 -89
View File
@@ -1,97 +1,11 @@
import { ActionButton } from "@/components/ActionButton"
import { Spinner } from "@/components/Icons"
import { cn } from "@/lib/utils"
import { unsafeApi$ } from "@/state/chains/chain.state"
import { selectedAccount$ } from "@/state/polkahub"
import { AccountPicker } from "@polkahub/ui-components"
import { useStateObservable } from "@react-rxjs/core"
import { Binary } from "polkadot-api"
import {
AddressIdentity,
useAvailableAccounts,
useSelectedAccount,
} from "polkahub"
import { FC, useState } from "react"
import { combineLatest, firstValueFrom } from "rxjs"
import { twMerge } from "tailwind-merge"
import { customSignedExtensions$ } from "../CustomSignedExt"
import { trackTx } from "../ExtrinsicsWorkspaceEntry"
import { ExtensionProvider } from "./ExtensionProvider"
const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
callData,
onClose,
}) => {
const account = useStateObservable(selectedAccount$)
const [isSigning, setIsSigning] = useState(false)
return (
<ActionButton
onClick={async () => {
if (!account?.signer) return
setIsSigning(true)
try {
const [unsafeApi, signedExt] = await firstValueFrom(
combineLatest([unsafeApi$, customSignedExtensions$]),
)
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
const signedExtrinsic = await tx.sign(account.signer, {
customSignedExtensions: signedExt as any,
})
trackTx(signedExtrinsic, tx.decodedCall, account)
onClose()
} catch (ex) {
console.error(ex)
}
setIsSigning(false)
}}
className="flex gap-2 items-center justify-center"
disabled={!account}
>
<span className={twMerge("ml-6", isSigning ? "" : "mr-6")}>
Sign and Submit
</span>
{isSigning && <Spinner size={16} />}
</ActionButton>
)
}
const SubmitUnsigned: FC<{ callData: string; onClose: () => void }> = ({
callData,
onClose,
}) => {
return (
<ActionButton
onClick={async () => {
const unsafeApi = await firstValueFrom(unsafeApi$)
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
trackTx(await tx.getBareTx(), tx.decodedCall)
onClose()
}}
className="flex gap-2 items-center justify-center"
>
Submit without signing
</ActionButton>
)
}
export default function SubmitTxF(props: {
callData: string
onClose: () => void
}) {
return (
<>
<ExtensionProvider />
<SelectAccount />
<div className="flex flex-col gap-2">
<SignAndSubmit {...props} />
<SubmitUnsigned {...props} />
</div>
</>
)
}
import { FC } from "react"
const groupLabels: Record<string, string> = {
ledger: "Ledger",
@@ -100,7 +14,7 @@ const groupLabels: Record<string, string> = {
walletconnect: "Wallet Connect",
}
const SelectAccount: FC<{
export const SelectAccount: FC<{
className?: string
}> = ({ className }) => {
const availableAccounts = useAvailableAccounts()
@@ -117,7 +31,12 @@ const SelectAccount: FC<{
accounts,
}))
if (!groups.length && !account) return null
if (!groups.length && !account)
return (
<div className="w-full rounded border h-8 bg-muted text-muted-foreground flex items-center p-2">
Configure your signer to select one
</div>
)
return (
<AccountPicker
@@ -0,0 +1,55 @@
import { getHashParams } from "@/hashParams"
import { unsafeApi$ } from "@/state/chains/chain.state"
import {
CodecComponentType,
CodecComponentValue,
} from "@polkadot-api/react-builder"
import { state, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { Binary } from "polkadot-api"
import { fromHex } from "polkadot-api/utils"
import { catchError, concat, defer, map, of, switchMap, take } from "rxjs"
export const [codecComponentChange$, setComponentValue] =
createSignal<CodecComponentValue>()
export const codecComponentValue$ = state(
defer(() => {
const hashParamsData = getHashParams(location).get("data")
const initial$ = hashParamsData
? of(hashParamsData)
: unsafeApi$.pipe(
take(1),
switchMap((v) =>
v.tx.System.remark({ remark: new Uint8Array() }).getEncodedData(),
),
catchError(() => [new Uint8Array()]),
map(Binary.toHex),
)
return concat(
initial$.pipe(
map(
(value): CodecComponentValue => ({
type: CodecComponentType.Initial,
value,
}),
),
),
codecComponentChange$,
)
}),
)
export const getBinaryValue = (componentValue: CodecComponentValue) =>
(componentValue.type === CodecComponentType.Initial
? typeof componentValue.value === "string"
? fromHex(componentValue.value)
: componentValue.value
: componentValue.value.empty
? null
: componentValue.value.encoded) ?? null
export const callData$ = codecComponentValue$.pipeState(
map(getBinaryValue),
withDefault(null),
)
+3 -2
View File
@@ -9,6 +9,7 @@ import { getTypeComplexity } from "@/utils/shape"
import { useStateObservable } from "@react-rxjs/core"
import { useState } from "react"
import { Route, Routes, useParams } from "react-router-dom"
import { CenteredScrollContainer } from "../AppShell"
import { Extrinsic } from "./Extrinsic"
import { Lookup, LookupContext } from "./Lookup"
import { Pallets } from "./Pallets"
@@ -81,7 +82,7 @@ const MetadataExplorer = () => {
].filter((v) => !v.disabled)
return (
<div className="p-4 pb-0 flex flex-col overflow-auto items-start gap-2">
<CenteredScrollContainer className="p-4 pb-0 flex flex-col overflow-auto items-start gap-2">
<ButtonGroup
value={mode}
onValueChange={setMode as any}
@@ -95,7 +96,7 @@ const MetadataExplorer = () => {
{tabs.find((t) => t.id === mode)?.element}
</div>
</LookupContext.Provider>
</div>
</CenteredScrollContainer>
)
}
+4 -3
View File
@@ -3,12 +3,13 @@ import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { chainClient$ } from "@/state/chains/chain.state"
import { useTheme } from "@/ThemeProvider"
import { state, useStateObservable } from "@react-rxjs/core"
import { lazy, Suspense, useRef, useState } from "react"
import { firstValueFrom, map, switchMap } from "rxjs"
import { CenteredScrollContainer } from "../AppShell"
import { RpcCallResults } from "./RpcCallResults"
import { addRpcCallQuery } from "./rpcCalls.state"
import { useTheme } from "@/ThemeProvider"
const Editor = lazy(() => import("@monaco-editor/react"))
@@ -49,7 +50,7 @@ export const RpcCalls = withSubscribe(
const isReady = !!method && isJson(params)
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<label className="w-full">
RPC
<SearchableSelect
@@ -116,7 +117,7 @@ export const RpcCalls = withSubscribe(
Call
</ActionButton>
<RpcCallResults />
</div>
</CenteredScrollContainer>
)
},
{
+3 -2
View File
@@ -10,6 +10,7 @@ import { map } from "rxjs"
import { RuntimeCallQuery } from "./RuntimeCallQuery"
import { RuntimeCallResults } from "./RuntimeCallResults"
import { selectedEntry$, setSelectedMethod } from "./runtimeCalls.state"
import { CenteredScrollContainer } from "../AppShell"
const metadataRuntimeCalls$ = state(
lookup$.pipe(
@@ -57,7 +58,7 @@ export const RuntimeCalls = withSubscribe(
}, [selectedApi, method])
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-2">
<label>
API
@@ -96,7 +97,7 @@ export const RuntimeCalls = withSubscribe(
<Routes>
<Route path=":callId" element={<RuntimeCallResults />} />
</Routes>
</div>
</CenteredScrollContainer>
)
},
{
+3 -2
View File
@@ -11,15 +11,16 @@ import { StorageDecode } from "./StorageDecode"
import { StorageQuery } from "./StorageQuery"
import { StorageSet } from "./StorageSet"
import { StorageSubscriptions } from "./StorageSubscriptions"
import { CenteredScrollContainer } from "../AppShell"
export const Storage = withSubscribe(
() => (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<StorageEntry />
<Routes>
<Route path=":subId" element={<StorageSubscriptions />} />
</Routes>
</div>
</CenteredScrollContainer>
),
{
fallback: <LoadingMetadata />,
+5 -4
View File
@@ -1,15 +1,16 @@
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 { lookup$ } from "@/state/chains/chain.state"
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 { ViewFnQuery } from "./ViewFnQuery"
import { ViewFnResults } from "./ViewFnResults"
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
import { ViewFnQuery } from "./ViewFnQuery"
const metadataViewFns$ = state(
lookup$.pipe(
@@ -67,7 +68,7 @@ export const ViewFns = withSubscribe(
}, [selectedPallet, fnName])
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<CenteredScrollContainer className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-2">
<label>
Pallet
@@ -106,7 +107,7 @@ export const ViewFns = withSubscribe(
<Routes>
<Route path=":callId" element={<ViewFnResults />} />
</Routes>
</div>
</CenteredScrollContainer>
)
},
{