diff --git a/src/components/RuntimeMetadataLoader.tsx b/src/components/RuntimeMetadataLoader.tsx new file mode 100644 index 00000000..1d21e5f2 --- /dev/null +++ b/src/components/RuntimeMetadataLoader.tsx @@ -0,0 +1,151 @@ +/** @jsxImportSource @emotion/react */ +import { LinearProgress } from "@mui/material"; +import { css, Theme } from "@emotion/react"; + +import { ReactComponent as Logo } from "../assets/calamar-logo-export-05.svg"; +import Background from "../assets/main-screen-bgr.svg"; + +import { Footer } from "./Footer"; + +import { usePreloadRuntimeMetadata } from "../hooks/usePreloadRuntimeMetadata"; +import { Outlet } from "react-router-dom"; + +const containerStyle = (theme: Theme) => css` + --content-min-height: 900px; + + width: 100%; + margin: 0; + display: flex; + flex-direction: column; + align-items: stretch; + + ${theme.breakpoints.up("sm")} { + --content-min-height: 1000px; + } + + ${theme.breakpoints.up("md")} { + --content-min-height: 1100px; + } + + ${theme.breakpoints.up("lg")} { + --content-min-height: 1200px; + } + + ${theme.breakpoints.up("xl")} { + --content-min-height: 1300px; + } +`; + +const contentStyle = css` + position: relative; + flex: 1 1 auto; + min-height: var(--content-min-height); +`; + +const backgroundStyle = css` + position: absolute; + top: 0; + margin: 0; + width: 100%; + height: 100%; + min-height: 100vh; + z-index: -1; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: var(--content-min-height); + background-color: white; + background-position: center bottom; + background-size: 100% auto; + background-repeat: no-repeat; + background-image: url(${Background}); + } + + &::after { + content: ''; + position: absolute; + top: var(--content-min-height); + left: 0; + right: 0; + bottom: 0; + background-color: #9af0f7; + } +`; + +const logoStyle = css` + width: 420px; + margin: 40px auto; + display: block; + max-width: 100%; +`; + +const subtitleStyle = (theme: Theme) => css` + position: relative; + top: -100px; + padding: 0 16px; + font-size: 16px; + text-align: center; + + ${theme.breakpoints.down("sm")} { + top: -70px; + } +`; + +const footerStyle = css` + flex: 0 0 auto; + + > div { + max-width: 1000px; + } +`; + +const metadatLoadingStyle = css` + max-width: 500px; + margin: 0 auto; + padding: 0 16px; + + text-align: center; +`; + +const metadataProgressStyle = css` + margin-bottom: 16px; + height: 8px; + + border-radius: 4px; + background-color: #e1fbfd; + + .MuiLinearProgress-bar { + background-color: #7acbdd; + } +`; + +export const RuntimeMetadataLoader = () => { + const metadataPreload = usePreloadRuntimeMetadata(); + + if (metadataPreload.loading) { + return ( +
+
+
+ +
Block explorer for Polkadot & Kusama ecosystem
+
+ + Loading latest runtime metadata ... +
+
+
+
+ ); + } + + return ; +}; diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx index f38e9a1e..1d14d011 100644 --- a/src/components/SearchInput.tsx +++ b/src/components/SearchInput.tsx @@ -1,15 +1,21 @@ /** @jsxImportSource @emotion/react */ -import { FormHTMLAttributes, useCallback, useEffect, useState } from "react"; +import { FormHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { Button, FormGroup, TextField } from "@mui/material"; +import { Autocomplete, Button, FormGroup, TextField, debounce } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import { css, Theme } from "@emotion/react"; +import { useAutocompleteSearchQuery } from "../hooks/useAutocompleteSearchQuery"; import { Network } from "../model/network"; import { getNetworks } from "../services/networksService"; import { NetworkSelect } from "./NetworkSelect"; +const formStyle = css` + position: relative; + text-align: left; +`; + const formGroupStyle = css` flex-direction: row; justify-content: center; @@ -17,7 +23,7 @@ const formGroupStyle = css` `; const networkSelectStyle = (theme: Theme) => css` - flex: 1 0 auto; + flex: 0 0 auto; border-top-right-radius: 0; border-bottom-right-radius: 0; @@ -47,12 +53,24 @@ const networkSelectStyle = (theme: Theme) => css` min-width: 0; } - .MuiListItemText-root { + > span { display: none; } } `; +const inputStyle = css` + flex: 1 0 auto; + + .MuiOutlinedInput-root { + padding: 0 !important; + + .MuiAutocomplete-input { + padding: 12px 16px; + } + } +`; + const textFieldStyle = css` .MuiInputBase-root { border-radius: 0; @@ -70,6 +88,24 @@ const textFieldStyle = css` } `; +const autocompleteNameStyle = css` + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 16px; +`; + +const autocompleteTypeStyle = css` + margin-left: auto; + flex: 0 0 auto; + font-size: 12px; + opacity: .75; + border: solid 1px gray; + border-radius: 8px; + padding: 0 4px; + background-color: rgba(0, 0, 0, .025); +`; + const buttonStyle = (theme: Theme) => css` border-radius: 8px; border-top-left-radius: 0px; @@ -99,6 +135,14 @@ const buttonStyle = (theme: Theme) => css` } `; +function storeNetworks(networks: Network[]) { + localStorage.setItem("networks", JSON.stringify(networks.map(it => it.name))); +} + +function loadNetworks() { + return getNetworks(JSON.parse(localStorage.getItem("networks") || "[]")); +} + export type SearchInputProps = FormHTMLAttributes & { persist?: boolean; defaultNetworks?: Network[]; @@ -109,13 +153,17 @@ function SearchInput(props: SearchInputProps) { const [qs] = useSearchParams(); + const navigate = useNavigate(); + + const formRef = useRef(null); + const [networks, setNetworks] = useState(defaultNetworks || getNetworks(qs.getAll("network") || [])); const [query, setQuery] = useState(qs.get("query") || ""); + const [autocompleteQuery, _setAutocompleteQuery] = useState(query || ""); - const navigate = useNavigate(); + const setAutocompleteQuery = useMemo(() => debounce(_setAutocompleteQuery, 250), []); - const storeNetworks = (networks: Network[]) => localStorage.setItem("networks", JSON.stringify(networks.map(it => it.name))); - const loadNetworks = () => getNetworks(JSON.parse(localStorage.getItem("networks") || "[]")); + const autocompleteSuggestions = useAutocompleteSearchQuery(autocompleteQuery, networks); const handleNetworkSelect = useCallback((networks: Network[], isUserAction: boolean) => { if (isUserAction && persist) { @@ -126,6 +174,11 @@ function SearchInput(props: SearchInputProps) { setNetworks(networks); }, [persist]); + const handleQueryChange = useCallback((ev: any, value: string) => { + setQuery(value); + setAutocompleteQuery(value); + }, []); + const handleSubmit = useCallback((ev: any) => { ev.preventDefault(); @@ -158,34 +211,67 @@ function SearchInput(props: SearchInputProps) { }, [persist]); return ( -
- - - setQuery(e.target.value)} - placeholder="Extrinsic hash / account address / block hash / block height / extrinsic name / event name" - value={query} - /> - - + + it} + inputValue={query} + onInputChange={handleQueryChange} + renderOption={(props, option) => ( +
  • +
    + {option.label.slice(0, option.highlight[0])} + {option.label.slice(option.highlight[0], option.highlight[1])} + {option.label.slice(option.highlight[1])} +
    +
    {option.type}
    +
  • + )} + componentsProps={{ + popper: { + anchorEl: formRef.current, + placement: "bottom-start", + style: { + width: "100%" + } + } + }} + renderInput={(params) => + + + + + + } + /> ); } diff --git a/src/components/network/NetworkStats.tsx b/src/components/network/NetworkStats.tsx index 42c084cb..8ed0b701 100644 --- a/src/components/network/NetworkStats.tsx +++ b/src/components/network/NetworkStats.tsx @@ -45,6 +45,7 @@ const statsLayoutStyle = css` display: grid; width: 100%; height: auto; + margin-bottom: 32px; gap: 10px; diff --git a/src/hooks/useAutocompleteSearchQuery.ts b/src/hooks/useAutocompleteSearchQuery.ts new file mode 100644 index 00000000..317b53a2 --- /dev/null +++ b/src/hooks/useAutocompleteSearchQuery.ts @@ -0,0 +1,12 @@ +import { Network } from "../model/network"; +import { autocompleteSearchQuery } from "../services/searchService"; + +import { UseResourceOptions, useResource } from "./useResource"; + +export function useAutocompleteSearchQuery( + query: string, + networks: Network[], + options?: UseResourceOptions +) { + return useResource(autocompleteSearchQuery, [query, networks], options); +} diff --git a/src/hooks/usePreloadRuntimeMetadata.ts b/src/hooks/usePreloadRuntimeMetadata.ts new file mode 100644 index 00000000..d2e6c8b9 --- /dev/null +++ b/src/hooks/usePreloadRuntimeMetadata.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { getNetworks } from "../services/networksService"; +import { getLatestRuntimeSpecVersion } from "../services/runtimeSpecService"; +import { loadRuntimeMetadata } from "../services/runtimeMetadataService"; + +export function usePreloadRuntimeMetadata() { + const [progress, setProgress] = useState(localStorage.getItem("runtime-metadata-preloaded") ? 100 : 0); + + useEffect(() => { + if (localStorage.getItem("skip-runtime-metadata-preload")) { + return; + } + + Promise.allSettled(getNetworks().map(async (it) => { + try { + const specVersion = await getLatestRuntimeSpecVersion(it.name); + await loadRuntimeMetadata(it.name, specVersion); + } catch (e) { + // pass + } + + setProgress(prev => prev + 100 / getNetworks().length); + })).then(() => { + setProgress(100); + localStorage.setItem("runtime-metadata-preloaded", "true"); + }); + }, []); + + return { + loading: progress < 100, + progress + }; +} diff --git a/src/model/runtime-metadata/runtimeMetadataAutocomplete.ts b/src/model/runtime-metadata/runtimeMetadataAutocomplete.ts new file mode 100644 index 00000000..ee6864b0 --- /dev/null +++ b/src/model/runtime-metadata/runtimeMetadataAutocomplete.ts @@ -0,0 +1,6 @@ +export interface RuntimeMetadataAutocomplete { + type: "pallet" | "call" | "event"; + name: string; + pallet?: string; + networks: string[]; +} diff --git a/src/repositories/runtimeMetadataRepository.ts b/src/repositories/runtimeMetadataRepository.ts index 6aa6ca5c..f3278d80 100644 --- a/src/repositories/runtimeMetadataRepository.ts +++ b/src/repositories/runtimeMetadataRepository.ts @@ -22,12 +22,12 @@ export class RuntimeMetadataRepository extends Dexie { this.version(1).stores({ specs: "[network+specVersion]", - pallets: "[network+specVersion+name],[network+specVersion]", - calls: "[network+specVersion+pallet+name],[network+specVersion+pallet]", - events: "[network+specVersion+pallet+name],[network+specVersion+pallet]", + pallets: "[network+specVersion+name],[network+specVersion],name", + calls: "[network+specVersion+pallet+name],[network+specVersion+pallet],name", + events: "[network+specVersion+pallet+name],[network+specVersion+pallet],name", constants: "[network+specVersion+pallet+name],[network+specVersion+pallet]", storages: "[network+specVersion+pallet+name],[network+specVersion+pallet]", - errors: "[network+specVersion+pallet+name],[network+specVersion+pallet]" + errors: "[network+specVersion+pallet+name],[network+specVersion+pallet]", }); } } diff --git a/src/router.tsx b/src/router.tsx index 6563fef3..d3f79af0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,7 +1,8 @@ import { LoaderFunctionArgs, Navigate, RouteObject, createBrowserRouter, redirect } from "react-router-dom"; import { ResultLayout } from "./components/ResultLayout"; -import { getNetwork } from "./services/networksService"; +import { RuntimeMetadataLoader } from "./components/RuntimeMetadataLoader"; + import { encodeAddress } from "./utils/address"; import { AccountPage } from "./screens/account"; @@ -20,6 +21,7 @@ import { simplifyCallId } from "./services/callsService"; import { simplifyBlockId } from "./services/blocksService"; import { simplifyExtrinsicId } from "./services/extrinsicsService"; import { simplifyEventId } from "./services/eventsService"; +import { getNetwork } from "./services/networksService"; import { normalizeCallName, normalizeConstantName, normalizeErrorName, normalizeEventName, normalizePalletName, normalizeStorageName } from "./services/runtimeMetadataService"; const networkLoader = ({ params }: LoaderFunctionArgs) => { @@ -35,252 +37,257 @@ const networkLoader = ({ params }: LoaderFunctionArgs) => { export const routes: RouteObject[] = [ { - path: "/", - element: , - }, - { - element: , + element: , children: [ { - path: "search/:tab?", - element: , - errorElement: , + index: true, + element: , }, { - id: "network", - path: ":network", - loader: networkLoader, - errorElement: , + element: , children: [ { - path: ":tab?", - element: , + path: "search/:tab?", + element: , + errorElement: , }, { - path: "block/:id/:tab?", - element: , - loader: (args) => { - const { params } = args; - const { id } = params as { id: string }; + id: "network", + path: ":network", + loader: networkLoader, + errorElement: , + children: [ + { + path: ":tab?", + element: , + }, + { + path: "block/:id/:tab?", + element: , + loader: (args) => { + const { params } = args; + const { id } = params as { id: string }; - const simplifiedId = simplifyBlockId(id); + const simplifiedId = simplifyBlockId(id); - if (id !== simplifiedId) { - const { network } = networkLoader(args); - return redirect(`/${network.name}/block/${simplifiedId}`); - } + if (id !== simplifiedId) { + const { network } = networkLoader(args); + return redirect(`/${network.name}/block/${simplifiedId}`); + } - return null; - } - }, - { - path: "extrinsic/:id/:tab?", - element: , - loader: (args) => { - const { params } = args; - const { id } = params as { id: string }; + return null; + } + }, + { + path: "extrinsic/:id/:tab?", + element: , + loader: (args) => { + const { params } = args; + const { id } = params as { id: string }; - const simplifiedId = simplifyExtrinsicId(id); + const simplifiedId = simplifyExtrinsicId(id); - if (id !== simplifiedId) { - const { network } = networkLoader(args); - return redirect(`/${network.name}/extrinsic/${simplifiedId}`); - } + if (id !== simplifiedId) { + const { network } = networkLoader(args); + return redirect(`/${network.name}/extrinsic/${simplifiedId}`); + } - return null; - } - }, - { - path: "call/:id/:tab?", - element: , - loader: (args) => { - const { params } = args; - const { id } = params as { id: string }; + return null; + } + }, + { + path: "call/:id/:tab?", + element: , + loader: (args) => { + const { params } = args; + const { id } = params as { id: string }; - const simplifiedId = simplifyCallId(id); + const simplifiedId = simplifyCallId(id); - if (id !== simplifiedId) { - const { network } = networkLoader(args); - return redirect(`/${network.name}/call/${simplifiedId}`); - } + if (id !== simplifiedId) { + const { network } = networkLoader(args); + return redirect(`/${network.name}/call/${simplifiedId}`); + } - return null; - } - }, - { - path: "event/:id", - element: , - loader: (args) => { - const { params } = args; - const { id } = params as { id: string }; + return null; + } + }, + { + path: "event/:id", + element: , + loader: (args) => { + const { params } = args; + const { id } = params as { id: string }; - const simplifiedId = simplifyEventId(id); + const simplifiedId = simplifyEventId(id); - if (id !== simplifiedId) { - const { network } = networkLoader(args); - return redirect(`/${network.name}/event/${simplifiedId}`); - } + if (id !== simplifiedId) { + const { network } = networkLoader(args); + return redirect(`/${network.name}/event/${simplifiedId}`); + } - return null; - } - }, - { - path: "account/:address/:tab?", - element: , - loader: (args) => { - const { params } = args; - const { address } = params as { address: string }; + return null; + } + }, + { + path: "account/:address/:tab?", + element: , + loader: (args) => { + const { params } = args; + const { address } = params as { address: string }; - const { network } = networkLoader(args); + const { network } = networkLoader(args); - const encodedAddress = encodeAddress(address, network.prefix); - if (address !== encodedAddress) { - return redirect(`/${network.name}/account/${encodedAddress}`); - } + const encodedAddress = encodeAddress(address, network.prefix); + if (address !== encodedAddress) { + return redirect(`/${network.name}/account/${encodedAddress}`); + } - return null; - } - }, - { - path: "latest-extrinsics", - element: , - }, - { - path: "runtime", - children: [ + return null; + } + }, { - index: true, - element: , + path: "latest-extrinsics", + element: , }, { - path: ":specVersion", + path: "runtime", children: [ { index: true, - element: , + element: , }, { - id: "runtime-pallet", - path: ":palletName", - loader: async (args) => { - const { params } = args; - const { specVersion, palletName } = params as { specVersion: string, palletName: string }; - - const { network } = networkLoader(args); - - console.log("pallet loader", specVersion, palletName); - - return { - palletName: await normalizePalletName(network, palletName, specVersion) - }; - }, + path: ":specVersion", children: [ { - path: ":tab?", - element: , + index: true, + element: , }, { - id: "runtime-call", - path: "calls/:callName", + id: "runtime-pallet", + path: ":palletName", loader: async (args) => { const { params } = args; - const { specVersion, palletName, callName } = params as { specVersion: string, palletName: string, callName: string }; + const { specVersion, palletName } = params as { specVersion: string, palletName: string }; const { network } = networkLoader(args); - const callFullName = `${palletName}.${callName}`; - const normalizedCallFullName = await normalizeCallName(network, callFullName, specVersion); + console.log("pallet loader", specVersion, palletName); return { - callName: normalizedCallFullName.split(".")[1] as string + palletName: await normalizePalletName(network, palletName, specVersion) }; }, - element: + children: [ + { + path: ":tab?", + element: , + }, + { + id: "runtime-call", + path: "calls/:callName", + loader: async (args) => { + const { params } = args; + const { specVersion, palletName, callName } = params as { specVersion: string, palletName: string, callName: string }; + + const { network } = networkLoader(args); + + const callFullName = `${palletName}.${callName}`; + const normalizedCallFullName = await normalizeCallName(network, callFullName, specVersion); + + return { + callName: normalizedCallFullName.split(".")[1] as string + }; + }, + element: + }, + { + id: "runtime-event", + path: "events/:eventName", + loader: async (args) => { + const { params } = args; + const { specVersion, palletName, eventName } = params as { specVersion: string, palletName: string, eventName: string }; + + const { network } = networkLoader(args); + + const eventFullName = `${palletName}.${eventName}`; + const normalizedEventFullName = await normalizeEventName(network, eventFullName, specVersion); + + return { + eventName: normalizedEventFullName.split(".")[1] as string + }; + }, + element: + }, + { + id: "runtime-constant", + path: "constants/:constantName", + loader: async (args) => { + const { params } = args; + const { specVersion, palletName, constantName } = params as { specVersion: string, palletName: string, constantName: string }; + + const { network } = networkLoader(args); + + const constantFullName = `${palletName}.${constantName}`; + const normalizedConstantFullName = await normalizeConstantName(network, constantFullName, specVersion); + + return { + constantName: normalizedConstantFullName.split(".")[1] as string + }; + }, + element: + }, + { + id: "runtime-storage", + path: "storages/:storageName", + loader: async (args) => { + const { params } = args; + const { specVersion, palletName, storageName } = params as { specVersion: string, palletName: string, storageName: string }; + + const { network } = networkLoader(args); + + const storageFullName = `${palletName}.${storageName}`; + const normalizedStorageFullName = await normalizeStorageName(network, storageFullName, specVersion); + + return { + storageName: normalizedStorageFullName.split(".")[1] as string + }; + }, + element: + }, + { + id: "runtime-error", + path: "errors/:errorName", + loader: async (args) => { + const { params } = args; + const { specVersion, palletName, errorName } = params as { specVersion: string, palletName: string, errorName: string }; + + const { network } = networkLoader(args); + + const errorFullName = `${palletName}.${errorName}`; + const normalizedErrorFullName = await normalizeErrorName(network, errorFullName, specVersion); + + return { + errorName: normalizedErrorFullName.split(".")[1] as string + }; + }, + element: + } + ] }, - { - id: "runtime-event", - path: "events/:eventName", - loader: async (args) => { - const { params } = args; - const { specVersion, palletName, eventName } = params as { specVersion: string, palletName: string, eventName: string }; - - const { network } = networkLoader(args); - - const eventFullName = `${palletName}.${eventName}`; - const normalizedEventFullName = await normalizeEventName(network, eventFullName, specVersion); - - return { - eventName: normalizedEventFullName.split(".")[1] as string - }; - }, - element: - }, - { - id: "runtime-constant", - path: "constants/:constantName", - loader: async (args) => { - const { params } = args; - const { specVersion, palletName, constantName } = params as { specVersion: string, palletName: string, constantName: string }; - - const { network } = networkLoader(args); - - const constantFullName = `${palletName}.${constantName}`; - const normalizedConstantFullName = await normalizeConstantName(network, constantFullName, specVersion); - - return { - constantName: normalizedConstantFullName.split(".")[1] as string - }; - }, - element: - }, - { - id: "runtime-storage", - path: "storages/:storageName", - loader: async (args) => { - const { params } = args; - const { specVersion, palletName, storageName } = params as { specVersion: string, palletName: string, storageName: string }; - - const { network } = networkLoader(args); - - const storageFullName = `${palletName}.${storageName}`; - const normalizedStorageFullName = await normalizeStorageName(network, storageFullName, specVersion); - - return { - storageName: normalizedStorageFullName.split(".")[1] as string - }; - }, - element: - }, - { - id: "runtime-error", - path: "errors/:errorName", - loader: async (args) => { - const { params } = args; - const { specVersion, palletName, errorName } = params as { specVersion: string, palletName: string, errorName: string }; - - const { network } = networkLoader(args); - - const errorFullName = `${palletName}.${errorName}`; - const normalizedErrorFullName = await normalizeErrorName(network, errorFullName, specVersion); - - return { - errorName: normalizedErrorFullName.split(".")[1] as string - }; - }, - element: - } ] - }, + } ] - } - ] - }, - { - path: "*", - element: , + }, + { + path: "*", + element: , + }, + ], }, - ], - }, + ] + } ] } ]; diff --git a/src/screens/home.tsx b/src/screens/home.tsx index 16f9b763..79cae749 100644 --- a/src/screens/home.tsx +++ b/src/screens/home.tsx @@ -8,6 +8,7 @@ import SearchInput from "../components/SearchInput"; import { Footer } from "../components/Footer"; import { Card } from "../components/Card"; import { ButtonLink } from "../components/ButtonLink"; + import { useNetworkGroups } from "../hooks/useNetworkGroups"; const containerStyle = (theme: Theme) => css` @@ -109,8 +110,7 @@ const searchInputStyle = (theme: Theme) => css` flex: 1 1 auto; .MuiInputBase-root { - .MuiInputBase-input, - .MuiSelect-select { + &.MuiOutlinedInput-root .MuiAutocomplete-input { padding: 16px 24px; } } @@ -213,7 +213,7 @@ export const HomePage = () => { persist />
    -
    +
    {networkGroups.map((group) =>
    diff --git a/src/services/runtimeMetadataService.ts b/src/services/runtimeMetadataService.ts index 219e63ae..a617598b 100644 --- a/src/services/runtimeMetadataService.ts +++ b/src/services/runtimeMetadataService.ts @@ -1,4 +1,4 @@ -import { Table } from "dexie"; +import { Collection, Table } from "dexie"; import { Network } from "../model/network"; import { RuntimeMetadataCall } from "../model/runtime-metadata/runtimeMetadataCall"; @@ -16,57 +16,69 @@ import { RuntimeSpecWorker } from "../workers/runtimeSpecWorker"; export async function getRuntimeMetadataPallets(network: string, specVersion: string, filter?: (it: RuntimeMetadataPallet) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.pallets, {network, specVersion}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.pallets.where({network, specVersion}), filter).toArray(); +} + +export async function getRuntimeMetadataPalletsByName(namePrefix: string, filter?: (it: RuntimeMetadataPallet) => boolean) { + return applyFilter(runtimeMetadataRepository.pallets.where("name").startsWithIgnoreCase(namePrefix), filter).toArray(); } export async function getRuntimeMetadataCalls(network: string, specVersion: string, pallet: string, filter?: (it: RuntimeMetadataCall) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.calls, {network, specVersion, pallet}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.calls.where({network, specVersion, pallet}), filter).toArray(); +} + +export async function getRuntimeMetadataCallsByName(namePrefix: string, filter?: (it: RuntimeMetadataCall) => boolean) { + return applyFilter(runtimeMetadataRepository.calls.where("name").startsWithIgnoreCase(namePrefix), filter).toArray(); } export async function getRuntimeMetadataEvents(network: string, specVersion: string, pallet: string, filter?: (it: RuntimeMetadataEvent) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.events, {network, specVersion, pallet}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.events.where({network, specVersion, pallet}), filter).toArray(); +} + +export async function getRuntimeMetadataEventsByName(namePrefix: string, filter?: (it: RuntimeMetadataEvent) => boolean) { + return applyFilter(runtimeMetadataRepository.events.where("name").startsWithIgnoreCase(namePrefix), filter).toArray(); } export async function getRuntimeMetadataConstants(network: string, specVersion: string, pallet: string, filter?: (it: RuntimeMetadataConstant) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.constants, {network, specVersion, pallet}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.constants.where({network, specVersion, pallet}), filter).toArray(); } export async function getRuntimeMetadataStorages(network: string, specVersion: string, pallet: string, filter?: (it: RuntimeMetadataStorage) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.storages, {network, specVersion, pallet}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.storages.where({network, specVersion, pallet}), filter).toArray(); } export async function getRuntimeMetadataErrors(network: string, specVersion: string, pallet: string, filter?: (it: RuntimeMetadataError) => boolean) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.errors, {network, specVersion, pallet}, filter).toArray(); + return applyFilter(runtimeMetadataRepository.errors.where({network, specVersion, pallet}), filter).toArray(); } export async function getRuntimeMetadataCall(network: string, specVersion: string, pallet: string, name: string) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.calls, {network, specVersion, pallet, name}).first(); + return runtimeMetadataRepository.calls.where({network, specVersion, pallet, name}).first(); } export async function getRuntimeMetadataEvent(network: string, specVersion: string, pallet: string, name: string) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.events, {network, specVersion, pallet, name}).first(); + return runtimeMetadataRepository.events.where({network, specVersion, pallet, name}).first(); } export async function getRuntimeMetadataConstant(network: string, specVersion: string, pallet: string, name: string) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.constants, {network, specVersion, pallet, name}).first(); + return runtimeMetadataRepository.constants.where({network, specVersion, pallet, name}).first(); } export async function getRuntimeMetadataStorage(network: string, specVersion: string, pallet: string, name: string) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.storages, {network, specVersion, pallet, name}).first(); + return runtimeMetadataRepository.storages.where({network, specVersion, pallet, name}).first(); } export async function getRuntimeMetadataError(network: string, specVersion: string, pallet: string, name: string) { await loadRuntimeMetadata(network, specVersion); - return queryStore(runtimeMetadataRepository.errors, {network, specVersion, pallet, name}).first(); + return runtimeMetadataRepository.errors.where({network, specVersion, pallet, name}).first(); } export async function normalizePalletName(network: Network, name: string, specVersion: string) { @@ -147,7 +159,7 @@ export async function normalizeErrorName(network: Network, name: string, specVer /*** PRIVATE ***/ -async function loadRuntimeMetadata(network: string, specVersion: string) { +export async function loadRuntimeMetadata(network: string, specVersion: string) { await self.navigator.locks.request(`runtime-metadata/${network}/${specVersion}`, async () => { const spec = await runtimeMetadataRepository.specs.get([network, specVersion]); @@ -165,9 +177,7 @@ async function loadRuntimeMetadata(network: string, specVersion: string) { }); } -function queryStore(table: Table, where: Record, filter?: (it: T) => boolean) { - let collection = table.where(where); - +function applyFilter(collection: Collection, filter?: (it: T) => boolean) { if (filter) { collection = collection.filter(filter); } diff --git a/src/services/searchService.ts b/src/services/searchService.ts index 186371ea..87a467ee 100644 --- a/src/services/searchService.ts +++ b/src/services/searchService.ts @@ -24,13 +24,14 @@ import { NetworkError, NonFatalError } from "../utils/error"; import { extractConnectionItems, paginationToConnectionCursor } from "../utils/itemsConnection"; import { emptyItemsResponse } from "../utils/itemsResponse"; import { PickByType } from "../utils/types"; +import { uniqBy } from "../utils/uniq"; import { BlocksFilter, blocksFilterToArchiveFilter, blocksFilterToExplorerSquidFilter, unifyArchiveBlock, unifyExplorerSquidBlock } from "./blocksService"; import { addEventsArgs, eventsFilterToExplorerSquidFilter, unifyExplorerSquidEvent } from "./eventsService"; import { ExtrinsicsFilter, extrinsicFilterToArchiveFilter, extrinsicFilterToExplorerSquidFilter, unifyArchiveExtrinsic, unifyExplorerSquidExtrinsic } from "./extrinsicsService"; import { fetchArchive, fetchExplorerSquid } from "./fetchService"; import { getNetwork, hasSupport } from "./networksService"; -import { normalizeCallName, normalizeEventName } from "./runtimeMetadataService"; +import { getRuntimeMetadataCallsByName, getRuntimeMetadataEventsByName, getRuntimeMetadataPalletsByName, normalizeCallName, normalizeEventName } from "./runtimeMetadataService"; import { getLatestRuntimeSpecVersion } from "./runtimeSpecService"; export type SearchPaginationOptions = Record>, PaginationOptions>; @@ -136,6 +137,58 @@ export async function search(query: string, networks: Network[], pagination: Sea }; } +export async function autocompleteSearchQuery(query: string, networks: Network[]) { + if (!query) { + return []; + } + + const networkNames = networks.map(it => it.name); + + let pallet: string|undefined; + + if (query.includes(".")) { + [pallet = "", query = ""] = query.split("."); + } + + console.log("autocomplete", pallet, query, networkNames); + + const pallets = await getRuntimeMetadataPalletsByName(query, it => (networkNames.length === 0 || networkNames.includes(it.network)) && !pallet); + const calls = await getRuntimeMetadataCallsByName(query, it => (networkNames.length === 0 || networkNames.includes(it.network)) && (!pallet || it.pallet === pallet)); + const events = await getRuntimeMetadataEventsByName(query, it => (networkNames.length === 0 || networkNames.includes(it.network)) && (!pallet || it.pallet === pallet)); + + const palletOptions = uniqBy(pallets, it => it.name).map(it => ({ + label: it.name, + highlight: [0, query.length], + type: "pallet" + })); + + const callOptions = uniqBy(calls, it => `${it.pallet}.${it.name}`).map(it => ({ + label: `${it.pallet}.${it.name}`, + highlight: [ + it.pallet.length + 1, + it.pallet.length + 1 + query.length + ], + type: "call" + })); + + const eventOptions = uniqBy(events, it => `${it.pallet}.${it.name}`).map(it => ({ + label: `${it.pallet}.${it.name}`, + highlight: [ + it.pallet.length + 1, + it.pallet.length + 1 + query.length + ], + type: "event" + })); + + return [ + ...palletOptions, + ...[ + ...callOptions, + ...eventOptions + ].sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())) + ]; +} + export function getQueryType(network: Network, query: string) { if (isHex(query)) { return "hash"; @@ -574,7 +627,10 @@ function networkResultsToSearchResultItems 0) { + // if positive total count found and also some data are fetched + // (extrinsics and events by name do not fetch data when searching multiple networks) + const item = items.data[0] as unknown as T; data.push({ @@ -582,11 +638,11 @@ function networkResultsToSearchResultItems 1) { + } else if (items.totalCount >= 1) { data.push({ id: `${result.network.name}-grouped`, network: result.network, - groupedCount: items.totalCount > 1 ? items.totalCount : undefined + groupedCount: items.totalCount }); } diff --git a/src/workers/runtimeSpecWorker.runtime.ts b/src/workers/runtimeSpecWorker.runtime.ts index cfe0094d..fe142584 100644 --- a/src/workers/runtimeSpecWorker.runtime.ts +++ b/src/workers/runtimeSpecWorker.runtime.ts @@ -10,6 +10,7 @@ import { fetchArchive } from "../services/fetchService"; import { WebWorkerRuntime } from "../utils/webWorker"; import { RuntimeSpecWorkerMethods } from "./runtimeSpecWorker"; +import { RuntimeMetadataAutocomplete } from "../model/runtime-metadata/runtimeMetadataAutocomplete"; /** * The reason to obtaining runtime metadata in a web worker is @@ -34,7 +35,6 @@ class RuntimeSpecWorkerRuntime extends WebWorkerRuntime implements RuntimeSpecWo ); console.log("hex downloaded", network, specVersion); - console.log(JSON.stringify(response)); response.metadata[0] && await this.decodeAndSaveRuntimeMetadata(network, specVersion, response.metadata[0].hex); } @@ -60,7 +60,7 @@ class RuntimeSpecWorkerRuntime extends WebWorkerRuntime implements RuntimeSpecWo repository.events, repository.constants, repository.storages, - repository.errors + repository.errors, ], async () => { await repository.specs.put({ network, diff --git a/test/e2e/home.spec.ts b/test/e2e/home.spec.ts index 3d6c2e24..5130f5b1 100644 --- a/test/e2e/home.spec.ts +++ b/test/e2e/home.spec.ts @@ -5,6 +5,12 @@ test.describe("home page", () => { test("shows home page", async ({ page, takeScreenshot }) => { await navigate(page, "/", {waitUntil: "load"}); + const $searchInput = page.getByTestId("search-input"); + const $networks = page.getByTestId("networks"); + + await expect($searchInput).toBeVisible(); + await expect($networks).toBeVisible(); + await takeScreenshot("home"); }); }); diff --git a/test/utils/navigate.ts b/test/utils/navigate.ts index f3719cc6..950503c2 100644 --- a/test/utils/navigate.ts +++ b/test/utils/navigate.ts @@ -13,6 +13,10 @@ const customEvents = ["data-loaded", "chart-finished"]; export async function navigate(page: Page, url: string, options: GotoOptions = {}) { let customEvent: string|undefined = undefined; + await page.goto(url, {waitUntil: "commit"}); + await page.evaluate(() => localStorage.setItem("skip-runtime-metadata-preload", "true")); + await page.evaluate(() => localStorage.setItem("runtime-metadata-preloaded", "true")); + if (options.waitUntil && customEvents.includes(options.waitUntil)) { customEvent = options.waitUntil; options.waitUntil = "domcontentloaded";