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:
+5
-5
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user