diff --git a/package.json b/package.json index 5fff055..a2d68ad 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "react18-json-view": "^0.2.8", "rxjs": "^7.8.1", "save-as": "^0.1.8", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "uuid": "^10.0.0" }, "devDependencies": { "@eslint/js": "^9.11.1", @@ -40,6 +41,7 @@ "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@types/react-portal": "^4.0.7", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "eslint": "^9.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9afe7b..2f7c03f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: tailwind-merge: specifier: ^2.5.4 version: 2.5.4 + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@eslint/js': specifier: ^9.11.1 @@ -89,6 +92,9 @@ importers: '@types/react-portal': specifier: ^4.0.7 version: 4.0.7 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@vitejs/plugin-react': specifier: ^4.3.2 version: 4.3.3(vite@5.4.9(@types/node@22.7.8)) @@ -1037,6 +1043,9 @@ packages: '@types/react@18.3.11': resolution: {integrity: sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@typescript-eslint/eslint-plugin@8.11.0': resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2276,6 +2285,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3217,6 +3230,8 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 + '@types/uuid@10.0.0': {} + '@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.11.1 @@ -4478,6 +4493,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/src/components/JsonDisplay.tsx b/src/components/JsonDisplay.tsx new file mode 100644 index 0000000..2191c82 --- /dev/null +++ b/src/components/JsonDisplay.tsx @@ -0,0 +1,30 @@ +import { bytesToString } from "@/components/BinaryInput" +import { Binary } from "@polkadot-api/substrate-bindings" +import { FC } from "react" +import ReactJson from "react18-json-view" +import "react18-json-view/src/dark.css" +import "react18-json-view/src/style.css" +import "./jsonDisplay.css" + +export const JsonDisplay: FC<{ + src: unknown +}> = ({ src }) => ( + (v instanceof Binary ? bytesToString(v) : v)} + customizeCopy={(v) => + JSON.stringify( + v, + (_, v) => + typeof v === "bigint" + ? `${v}n` + : v instanceof Binary + ? bytesToString(v) + : v, + 2, + ) + } + /> +) diff --git a/src/components/jsonDisplay.css b/src/components/jsonDisplay.css new file mode 100644 index 0000000..2e25b2a --- /dev/null +++ b/src/components/jsonDisplay.css @@ -0,0 +1,9 @@ +.json-view .json-view--copy { + opacity: 0.4; +} +.json-view .json-view--copy:hover { + opacity: 1; +} +.json-view .jv-button { + padding: 0 0.3rem; +} \ No newline at end of file diff --git a/src/pages/Extrinsics/JsonMode.tsx b/src/pages/Extrinsics/JsonMode.tsx index 04e2731..9869d8b 100644 --- a/src/pages/Extrinsics/JsonMode.tsx +++ b/src/pages/Extrinsics/JsonMode.tsx @@ -1,10 +1,5 @@ -import { bytesToString } from "@/components/BinaryInput" -import { Binary } from "@polkadot-api/substrate-bindings" +import { JsonDisplay } from "@/components/JsonDisplay" import { FC, useMemo } from "react" -import ReactJson from "react18-json-view" -import "react18-json-view/src/style.css" -import "react18-json-view/src/dark.css" -import "./jsonView.css" export const JsonMode: FC<{ decode: (value: Uint8Array) => unknown @@ -14,24 +9,7 @@ export const JsonMode: FC<{ return (
- (v instanceof Binary ? bytesToString(v) : v)} - customizeCopy={(v) => - JSON.stringify( - v, - (_, v) => - typeof v === "bigint" - ? `${v}n` - : v instanceof Binary - ? bytesToString(v) - : v, - 2, - ) - } - /> +
) } diff --git a/src/pages/Storage/Storage.tsx b/src/pages/Storage/Storage.tsx index 33a8d78..67343b5 100644 --- a/src/pages/Storage/Storage.tsx +++ b/src/pages/Storage/Storage.tsx @@ -5,8 +5,9 @@ import { withSubscribe } from "@/components/withSuspense" import { state, useStateObservable } from "@react-rxjs/core" import { FC, useEffect, useState } from "react" import { map } from "rxjs" -import { StorageQuery } from "./StorageQuery" import { selectedEntry$, setSelectedEntry } from "./storage.state" +import { StorageQuery } from "./StorageQuery" +import { StorageSubscriptions } from "./StorageSubscriptions" const metadataStorage$ = state( lookup$.pipe( @@ -111,6 +112,7 @@ export const Storage = withSubscribe(() => { )} + ) }) diff --git a/src/pages/Storage/StorageQuery.tsx b/src/pages/Storage/StorageQuery.tsx index 99ddb50..4081036 100644 --- a/src/pages/Storage/StorageQuery.tsx +++ b/src/pages/Storage/StorageQuery.tsx @@ -1,6 +1,7 @@ -import { lookup$ } from "@/chain.state" +import { lookup$, unsafeApi$ } from "@/chain.state" import { EditCodec } from "@/codec-components/EditCodec" import { ActionButton } from "@/components/ActionButton" +import { bytesToString } from "@/components/BinaryInput" import { CopyText } from "@/components/Copy" import { BinaryEdit } from "@/components/Icons" import SliderToggle from "@/components/Toggle" @@ -14,19 +15,59 @@ import { state, useStateObservable, withDefault } from "@react-rxjs/core" import { createSignal } from "@react-rxjs/utils" import { Binary } from "polkadot-api" import { FC, useState } from "react" -import { combineLatest, filter, map, scan, startWith, switchMap } from "rxjs" +import { + combineLatest, + filter, + firstValueFrom, + from, + map, + scan, + startWith, + switchMap, +} from "rxjs" import { twMerge } from "tailwind-merge" import { BinaryEditModal } from "../Extrinsics/BinaryEditModal" -import { selectedEntry$ } from "./storage.state" +import { addStorageSubscription, selectedEntry$ } from "./storage.state" export const StorageQuery: FC = () => { const isReady = useStateObservable(isReady$) + const submit = async () => { + const [entry, unsafeApi, keyValues, keysEnabled] = await firstValueFrom( + combineLatest([selectedEntry$, unsafeApi$, keyValues$, keysEnabled$]), + ) + const args = keyValues.slice(0, keysEnabled) + const storageEntry = unsafeApi.query[entry!.pallet][entry!.entry] + const single = keyValues.length === keysEnabled + const stream = single + ? storageEntry.watchValue(...args) + : from(storageEntry.getEntries(...args)) + + const stringifyArg = (value: unknown) => { + if (typeof value === "object" && value !== null) { + if (value instanceof Binary) { + return bytesToString(value) + } + return "arg" + } + return JSON.stringify(value) + } + + addStorageSubscription({ + name: `${entry!.pallet}.${entry!.entry}(${args.map(stringifyArg).join(", ")})`, + single, + stream, + type: entry!.value, + }) + } + return (
- Query + + Query +
) } diff --git a/src/pages/Storage/StorageSubscriptions.tsx b/src/pages/Storage/StorageSubscriptions.tsx new file mode 100644 index 0000000..7f2456d --- /dev/null +++ b/src/pages/Storage/StorageSubscriptions.tsx @@ -0,0 +1,58 @@ +import { useStateObservable } from "@react-rxjs/core" +import { FC } from "react" +import { + removeStorageSubscription, + storageSubscription$, + storageSubscriptionKeys$, +} from "./storage.state" +import { JsonDisplay } from "@/components/JsonDisplay" +import { Trash2 } from "lucide-react" + +export const StorageSubscriptions: FC = () => { + const keys = useStateObservable(storageSubscriptionKeys$) + + if (!keys.length) return null + + return ( +
+

Results

+
    + {keys.map((key) => ( + + ))} +
+
+ ) +} + +const StorageSubscriptionBox: FC<{ subscription: string }> = ({ + subscription, +}) => { + const storageSubscription = useStateObservable( + storageSubscription$(subscription), + ) + + if (!storageSubscription) return null + + return ( +
  • +
    +

    + {storageSubscription.name} +

    + +
    + {"result" in storageSubscription ? ( + + ) : ( +
    Loading…
    + )} +
  • + ) +} diff --git a/src/pages/Storage/storage.state.ts b/src/pages/Storage/storage.state.ts index 986fcae..3ea1443 100644 --- a/src/pages/Storage/storage.state.ts +++ b/src/pages/Storage/storage.state.ts @@ -1,5 +1,12 @@ import { state } from "@react-rxjs/core" -import { createSignal } from "@react-rxjs/utils" +import { + createKeyedSignal, + createSignal, + partitionByKey, + toKeySet, +} from "@react-rxjs/utils" +import { map, Observable, startWith, switchMap, takeUntil } from "rxjs" +import { v4 as uuid } from "uuid" export type StorageMetadataEntry = { pallet: string @@ -11,3 +18,49 @@ export type StorageMetadataEntry = { export const [entryChange$, setSelectedEntry] = createSignal() export const selectedEntry$ = state(entryChange$, null) + +export const [newStorageSubscription$, addStorageSubscription] = createSignal<{ + name: string + type: number + single: boolean + stream: Observable +}>() +export const [removeStorageSubscription$, removeStorageSubscription] = + createKeyedSignal() + +export type StorageSubscription = { + name: string + type: number + single: boolean +} & ({ result: unknown } | {}) +const [getStorageSubscription$, storageSubscriptionKeyChange$] = partitionByKey( + newStorageSubscription$, + () => uuid(), + (src$, id) => + src$.pipe( + switchMap( + ({ stream, ...props }): Observable => + stream.pipe( + map((result) => ({ + ...props, + result, + })), + startWith(props), + ), + ), + takeUntil(removeStorageSubscription$(id)), + ), +) + +export const storageSubscriptionKeys$ = state( + storageSubscriptionKeyChange$.pipe( + toKeySet(), + map((keys) => [...keys].reverse()), + ), + [], +) + +export const storageSubscription$ = state( + (key: string) => getStorageSubscription$(key), + null, +)