diff --git a/.vscode/settings.json b/.vscode/settings.json index 5cece89c..6100946d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,7 +16,6 @@ "javascript.validate.enable": false, "javascript.format.enable": false, "typescript.format.enable": false, - "search.exclude": { ".eslintcache": true, ".git": true, @@ -26,18 +25,15 @@ "test/**/__snapshots__": true, "yarn.lock": true }, - // Disable organize imports on save to prevent conflicts with Prettier, keep ESLint fixes "editor.codeActionsOnSave": { "source.organizeImports": "never", "source.fixAll.eslint": "explicit", "source.fixAll.ts": "explicit" }, - // Use Prettier as the default formatter for consistent import ordering "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - // Language-specific settings "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/src/api/index.ts b/src/api/index.ts index 5d1d794e..073b9ce3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -11,10 +11,14 @@ export const endpoint = ( if (!ssvNetwork) { throw new Error(`SSV network details not found for chainId: ${chainName}`) } - return urlJoin( + const url = urlJoin( ssvNetwork.api, ssvNetwork.apiVersion, ssvNetwork.apiNetwork, ...paths.map(String) ) + + // For debugging purposes + if (process.env.NODE_ENV === "development") console.log("endpoint:", url) + return url } diff --git a/src/api/validators.ts b/src/api/validators.ts index e4282042..77f8ec59 100644 --- a/src/api/validators.ts +++ b/src/api/validators.ts @@ -19,16 +19,7 @@ export const searchValidators = async ( ) => await unstable_cache( async () => { - const augmentedParams = { - ...params, - effectiveBalance: params.effectiveBalance - ? (params.effectiveBalance.map( - (value) => +formatGwei(parseEther(value.toString())) - ) as [number, number]) - : params.effectiveBalance, - } - - const searchParams = validatorsSearchParamsSerializer(augmentedParams) + const searchParams = validatorsSearchParamsSerializer(params) const url = endpoint(params.network, "validators", searchParams) const response = await api.get(url) diff --git a/src/app/_components/accounts/account-table-filters.tsx b/src/app/_components/accounts/account-table-filters.tsx index ed353031..1e59541a 100644 --- a/src/app/_components/accounts/account-table-filters.tsx +++ b/src/app/_components/accounts/account-table-filters.tsx @@ -49,7 +49,11 @@ export const AccountTableFilters = () => { step={1} decimals={0} /> - + + name="Effective Balance" + searchQueryKey="effectiveBalance" + parser={accountsSearchFilters.effectiveBalance} + /> name="Operators" searchQueryKey="operators" diff --git a/src/app/_components/clusters/cluster-table-filters.tsx b/src/app/_components/clusters/cluster-table-filters.tsx index e5c50424..3541fff5 100644 --- a/src/app/_components/clusters/cluster-table-filters.tsx +++ b/src/app/_components/clusters/cluster-table-filters.tsx @@ -63,7 +63,11 @@ export const ClusterTableFilters = ({ /> )} - + + name="Effective Balance" + searchQueryKey="effectiveBalance" + parser={clustersSearchFilters.effectiveBalance} + /> {enabledFilters.count > 0 && ( diff --git a/src/app/_components/operators/filters/managed-eth-filter.tsx b/src/app/_components/operators/filters/managed-eth-filter.tsx deleted file mode 100644 index 7433f178..00000000 --- a/src/app/_components/operators/filters/managed-eth-filter.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import { isEqual } from "lodash-es" - -import { operatorSearchFilters } from "@/lib/search-parsers/operator-search-parsers" -import { useOperatorsSearchParams } from "@/hooks/search/use-custom-search-params" -import { Text } from "@/components/ui/text" -import { FilterButton } from "@/components/filter/filter-button" -import { Range } from "@/components/filter/range-filter" - -export function ManagedEthFilter() { - const { filters, setFilters } = useOperatorsSearchParams() - - const defaultRange = operatorSearchFilters.effectiveBalance.defaultValue - - const isActive = - !isEqual(filters.effectiveBalance, defaultRange) && - Boolean(filters.effectiveBalance) - - const apply = (range: [number, number]) => { - const isCleared = isEqual(range, defaultRange) - setFilters((prev) => ({ - ...prev, - effectiveBalance: isCleared ? null : range, - })) - } - - const remove = () => { - apply(defaultRange) - } - - return ( - - - ETH - - ), - }, - end: { - rightSlot: ( - - ETH - - ), - }, - }} - /> - - ) -} diff --git a/src/app/_components/operators/filters/operator-table-filters.tsx b/src/app/_components/operators/filters/operator-table-filters.tsx index 263946dc..2a5d0ce7 100644 --- a/src/app/_components/operators/filters/operator-table-filters.tsx +++ b/src/app/_components/operators/filters/operator-table-filters.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button" import { textVariants } from "@/components/ui/text" import { MevRelaysFilter } from "@/app/_components/operators/filters/mev-relays-filter" import { HexFilter } from "@/app/_components/shared/filters/address-filter" +import { EffectiveBalanceFilter } from "@/app/_components/shared/filters/effective-balance-filter" import { EthFeeFilter } from "./eth-fee-filter" import { Eth1ClientFilter } from "./eth1-client-filter" @@ -18,7 +19,6 @@ import { Eth2ClientFilter } from "./eth2-client-filter" import { FeeFilter } from "./fee-filter" import { IdFilter } from "./id-filter" import { LocationFilter } from "./location-filter" -import { ManagedEthFilter } from "./managed-eth-filter" import { NameFilter } from "./name-filter" import { Performance24hFilter } from "./performance-24h-filter" import { Performance30dFilter } from "./performance-30d-filter" @@ -74,7 +74,11 @@ export const OperatorTableFilters = ({ - + + name="ETH Managed" + searchQueryKey="effectiveBalance" + parser={operatorSearchParsers.effectiveBalance} + /> diff --git a/src/app/_components/shared/filters/address-filter.tsx b/src/app/_components/shared/filters/address-filter.tsx index c8f8b5cc..1d11cf8a 100644 --- a/src/app/_components/shared/filters/address-filter.tsx +++ b/src/app/_components/shared/filters/address-filter.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { xor } from "lodash-es" import { X } from "lucide-react" -import { useQueryState, type ParserBuilder } from "nuqs" +import { SingleParserBuilder, useQueryState } from "nuqs" import { Collapse } from "react-collapse" import { MdKeyboardReturn } from "react-icons/md" import { type Address, type Hex } from "viem" @@ -24,7 +24,7 @@ import { FilterButton } from "@/components/filter/filter-button" type HexFilterProps = { name: string searchQueryKey: TSearchKey - parser: ParserBuilder + parser: SingleParserBuilder placeholder?: string invalidMessage?: string } diff --git a/src/app/_components/shared/filters/effective-balance-filter.tsx b/src/app/_components/shared/filters/effective-balance-filter.tsx index 53a437e8..7675aeb0 100644 --- a/src/app/_components/shared/filters/effective-balance-filter.tsx +++ b/src/app/_components/shared/filters/effective-balance-filter.tsx @@ -1,57 +1,59 @@ "use client" import { isEqual } from "lodash-es" +import { SingleParserBuilder, useQueryState } from "nuqs" -import { effectiveBalanceParser } from "@/lib/search-parsers/shared/parsers" -import { useCustomSearchParams } from "@/hooks/search/use-custom-search-params" +import { useDisclosure } from "@/hooks/use-disclosure" import { Text } from "@/components/ui/text" import { FilterButton } from "@/components/filter/filter-button" -import { Range } from "@/components/filter/range-filter" +import { OpenRange } from "@/components/filter/open-range-filter" -export function EffectiveBalanceFilter({ - searchParamsHook, -}: { - searchParamsHook: () => ReturnType< - typeof useCustomSearchParams<{ - effectiveBalance: typeof effectiveBalanceParser - }> - > -}) { - const { filters, setFilters } = searchParamsHook() +type Props = { + name: string + searchQueryKey: TSearchKey + parser: SingleParserBuilder<[number, number]> & { + defaultValue: [number, number] + } +} +export function EffectiveBalanceFilter({ + name, + searchQueryKey, + parser, +}: Props) { + const popup = useDisclosure() - const defaultRange = effectiveBalanceParser.defaultValue + const [searchRange, setSearchRange] = useQueryState(searchQueryKey, parser) + const defaultRange = parser.defaultValue - const isActive = - !isEqual(filters.effectiveBalance, defaultRange) && - Boolean(filters.effectiveBalance) + const isActive = !isEqual(searchRange, defaultRange) && Boolean(searchRange) const apply = (range: [number, number]) => { const isCleared = isEqual(range, defaultRange) - setFilters((prev) => ({ - ...prev, - effectiveBalance: isCleared ? null : range, - })) + setSearchRange(isCleared ? null : range) + popup.onOpenChange(false) } const remove = () => { apply(defaultRange) + popup.onOpenChange(false) } return ( - ETH @@ -66,6 +69,7 @@ export function EffectiveBalanceFilter({ ), }, end: { + placeholder: "To", rightSlot: ( ETH diff --git a/src/app/_components/validators/validator-table-filters.tsx b/src/app/_components/validators/validator-table-filters.tsx index b70ccbaa..58b6d605 100644 --- a/src/app/_components/validators/validator-table-filters.tsx +++ b/src/app/_components/validators/validator-table-filters.tsx @@ -86,7 +86,11 @@ export const ValidatorTableFilters = ({ searchQueryKey="dateRange" parser={validatorsSearchParsers.dateRange} /> - + + name="Effective Balance" + searchQueryKey="effectiveBalance" + parser={validatorsSearchParsers.effectiveBalance} + /> {enabledFilters.count > 0 && ( + + + + + ) +} diff --git a/src/components/ui/number-input.tsx b/src/components/ui/number-input.tsx index 3e1a7e65..690003bf 100644 --- a/src/components/ui/number-input.tsx +++ b/src/components/ui/number-input.tsx @@ -184,6 +184,7 @@ export interface NumberInputProps onChange: (value: number) => void decimals?: number step?: number + clearZeroOnMount?: boolean render?: ( props: { onInput: (ev: React.FormEvent) => void @@ -218,6 +219,7 @@ export const NumberInput: NumberInputFC = forwardRef< allowNegative = false, onChange, render, + clearZeroOnMount = false, ...props }, ref @@ -231,9 +233,10 @@ export const NumberInput: NumberInputFC = forwardRef< return new RegExp(decimals > 0 ? `${left}${right}` : left) }, [decimals]) - const [displayValue, setDisplayValue] = useState( - formatNumber(value, decimals) - ) + const [displayValue, setDisplayValue] = useState(() => { + if (!value && clearZeroOnMount) return "" + return formatNumber(value, decimals) + }) const [showMaxSet, setShowMaxSet] = useState(false) const [showMinSet, setShowMinSet] = useState(false) useDebounce(() => setShowMaxSet(false), 2500, [showMaxSet]) diff --git a/src/lib/search-parsers/clusters-search-parsers.ts b/src/lib/search-parsers/clusters-search-parsers.ts index 829d8c39..e9a068d4 100644 --- a/src/lib/search-parsers/clusters-search-parsers.ts +++ b/src/lib/search-parsers/clusters-search-parsers.ts @@ -14,7 +14,7 @@ import { addressesParser, clustersParser, defaultSearchOptions, - effectiveBalanceParser, + getEffectiveBalanceParser, } from "@/lib/search-parsers/shared/parsers" import { getSortingStateParser, parseAsTuple } from "@/lib/utils/parsers" @@ -33,7 +33,7 @@ export const clustersSearchFilters = { postParse: (values) => values.sort((a, b) => +a - +b), } ).withOptions(defaultSearchOptions), - effectiveBalance: effectiveBalanceParser, + effectiveBalance: getEffectiveBalanceParser({ serializeToGwei: false }), operatorDetails: parseAsBoolean .withOptions(defaultSearchOptions) .withDefault(true), diff --git a/src/lib/search-parsers/operator-search-parsers.ts b/src/lib/search-parsers/operator-search-parsers.ts index b304551c..85441f1f 100644 --- a/src/lib/search-parsers/operator-search-parsers.ts +++ b/src/lib/search-parsers/operator-search-parsers.ts @@ -14,6 +14,7 @@ import { paginationParser } from "@/lib/search-parsers" import { addressesParser, defaultSearchOptions, + getEffectiveBalanceParser, } from "@/lib/search-parsers/shared/parsers" import { MEV_RELAYS_VALUES, STATUS_API_VALUES } from "@/lib/utils/operator" import { getSortingStateParser, parseAsTuple } from "@/lib/utils/parsers" @@ -73,14 +74,7 @@ export const operatorSearchFilters = { ) .withDefault([0, 3000]) .withOptions(defaultSearchOptions), - effectiveBalance: parseAsTuple( - z.tuple([z.number({ coerce: true }), z.number({ coerce: true })]), - { - postParse: sortNumbers, - } - ) - .withDefault([0, 25000]) - .withOptions(defaultSearchOptions), + effectiveBalance: getEffectiveBalanceParser({ serializeToGwei: false }), status: parseAsArrayOf(z.enum(STATUS_API_VALUES)) .withDefault([]) .withOptions(defaultSearchOptions), diff --git a/src/lib/search-parsers/shared/parsers.ts b/src/lib/search-parsers/shared/parsers.ts index ad6f62be..efc7c3b5 100644 --- a/src/lib/search-parsers/shared/parsers.ts +++ b/src/lib/search-parsers/shared/parsers.ts @@ -1,5 +1,5 @@ -import { parseAsArrayOf, type Options } from "nuqs/server" -import { isAddress } from "viem" +import { createParser, parseAsArrayOf, type Options } from "nuqs/server" +import { formatGwei, isAddress, parseGwei } from "viem" import { z } from "zod" import { sortNumbers } from "@/lib/utils/number" @@ -45,3 +45,49 @@ export const effectiveBalanceParser = parseAsTuple( ) .withDefault([0, 25000]) .withOptions(defaultSearchOptions) + +const bigintTuple = z.tuple([ + z.bigint({ coerce: true }), + z.bigint({ coerce: true }), +]) + +type EBParserProps = { + /** + * If true, values are treated as eth and converted but converted to gwei in the URL (e.g. 32 -> 32e9) + * If false, no unit conversion is applied. + */ + serializeToGwei: boolean +} + +/** + * Creates an effective-balance range parser for URL search params. + * + * @param serializeToGwei - If true, values are treated as wei and converted + * to/from gwei when parsing and serializing. If false, no unit conversion is applied. + */ +export const getEffectiveBalanceParser = ({ + serializeToGwei, +}: EBParserProps) => { + return createParser<[number, number]>({ + parse: (value) => { + try { + const parsed = bigintTuple + .parse(value.split(",")) + .map((v) => Number(serializeToGwei ? formatGwei(v) : v)) + return parsed as [number, number] + } catch (error) { + return null + } + }, + serialize: ([_min, _max]) => { + const min = serializeToGwei ? Number(parseGwei(`${_min}`)) : _min + const max = serializeToGwei ? Number(parseGwei(`${_max}`)) : _max + if (min && max) return `${min},${max}` + if (min) return `${min},` + if (max) return `,${max}` + return "" + }, + }) + .withDefault([0, 0]) + .withOptions(defaultSearchOptions) +} diff --git a/src/lib/search-parsers/validators-search-parsers.ts b/src/lib/search-parsers/validators-search-parsers.ts index abe85f7b..f8e36470 100644 --- a/src/lib/search-parsers/validators-search-parsers.ts +++ b/src/lib/search-parsers/validators-search-parsers.ts @@ -15,6 +15,7 @@ import { addressesParser, clustersParser, defaultSearchOptions, + getEffectiveBalanceParser, publicKeysParser, } from "@/lib/search-parsers/shared/parsers" import { sortNumbers } from "@/lib/utils/number" @@ -44,14 +45,7 @@ export const validatorsSearchFilters = { postParse: sortNumbers, } ).withOptions(defaultSearchOptions), - effectiveBalance: parseAsTuple( - z.tuple([z.number({ coerce: true }), z.number({ coerce: true })]), - { - postParse: sortNumbers, - } - ) - .withDefault([0, 25000]) - .withOptions(defaultSearchOptions), + effectiveBalance: getEffectiveBalanceParser({ serializeToGwei: true }), } export type ValidatorSearchFilterKeys = keyof typeof validatorsSearchFilters