diff --git a/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx b/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx index b76aec07ef5..20f5b478b90 100644 --- a/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx +++ b/apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx @@ -1,6 +1,4 @@ "use client"; - -import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { CodeClient, CodeLoading } from "@/components/code/code.client"; import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { Spinner } from "@/components/ui/Spinner/Spinner"; @@ -35,6 +33,7 @@ import { useForm, } from "react-hook-form"; import { z } from "zod"; +import { MultiNetworkSelector } from "../../../components/blocks/NetworkSelectors"; import type { BlueprintParameter, BlueprintPathMetadata } from "../utils"; export function BlueprintPlayground(props: { @@ -156,7 +155,7 @@ export function BlueprintPlaygroundUI(props: { }, [parameters]); const defaultValues = useMemo(() => { - const values: Record = {}; + const values: Record = {}; for (const param of parameters) { if (param.schema && "type" in param.schema && param.schema.default) { values[param.name] = param.schema.default; @@ -164,6 +163,8 @@ export function BlueprintPlaygroundUI(props: { values[param.name] = Math.floor( (Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000, ); + } else if (param.name === "chain") { + values[param.name] = []; } else { values[param.name] = ""; } @@ -407,7 +408,7 @@ function RequestConfigSection(props: { } type ParametersForm = UseFormReturn<{ - [x: string]: string | number; + [x: string]: string | number | string[] | number[]; }>; function ParameterSection(props: { @@ -485,13 +486,15 @@ function ParameterSection(props: {
{param.name === "chain" ? ( - { - field.onChange({ - target: { value: chainId.toString() }, + onChange={(chainIds) => { + props.form.setValue("chain", chainIds, { + shouldValidate: true, + shouldDirty: true, }); }} className="rounded-none border-0 border-t lg:border-none" @@ -502,11 +505,14 @@ function ParameterSection(props: { : undefined } /> - ) : ( + ) : !Array.isArray(field.value) ? ( <> )} - )} + ) : null}
@@ -780,6 +786,26 @@ function openAPIV3ParamToZodFormSchema( return isRequired ? booleanSchema : booleanSchema.optional(); } + case "array": { + if ("type" in schema.items) { + let itemSchema: z.ZodTypeAny | undefined = undefined; + if (schema.items.type === "number") { + itemSchema = z.number(); + } else if (schema.items.type === "integer") { + itemSchema = z.number().int(); + } else if (schema.items.type === "string") { + itemSchema = z.string(); + } + + if (itemSchema) { + return isRequired + ? z.array(itemSchema) + : z.array(itemSchema).optional(); + } + } + break; + } + // everything else - just accept it as a string; default: { const stringSchema = z.string(); @@ -840,7 +866,13 @@ function createBlueprintUrl(options: { for (const parameter of queryParams) { const value = values[parameter.name]; if (value) { - searchParams.append(parameter.name, value); + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(parameter.name, v); + } + } else { + searchParams.append(parameter.name, value); + } } } diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx index 3f7e620e703..1a16107b1c9 100644 --- a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx +++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx @@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge"; import { useCallback, useMemo } from "react"; import { useAllChainsData } from "../../app/hooks/chains"; import { ChainIcon } from "./ChainIcon"; +import { MultiSelect } from "./multi-select"; import { SelectWithSearch } from "./select-with-search"; function cleanChainName(chainName: string) { @@ -12,6 +13,122 @@ function cleanChainName(chainName: string) { type Option = { label: string; value: string }; +export function MultiNetworkSelector(props: { + selectedChainIds: number[]; + onChange: (chainIds: number[]) => void; + disableChainId?: boolean; + className?: string; + priorityChains?: number[]; + popoverContentClassName?: string; + chainIds?: number[]; + selectedBadgeClassName?: string; +}) { + const { allChains, idToChain } = useAllChainsData().data; + + const chainsToShow = useMemo(() => { + if (!props.chainIds) { + return allChains; + } + const chainIdSet = new Set(props.chainIds); + return allChains.filter((chain) => chainIdSet.has(chain.chainId)); + }, [allChains, props.chainIds]); + + const options = useMemo(() => { + let sortedChains = chainsToShow; + + if (props.priorityChains) { + const priorityChainsSet = new Set(); + for (const chainId of props.priorityChains || []) { + priorityChainsSet.add(chainId); + } + + const priorityChains = (props.priorityChains || []) + .map((chainId) => { + return idToChain.get(chainId); + }) + .filter((v) => !!v); + + const otherChains = chainsToShow.filter( + (chain) => !priorityChainsSet.has(chain.chainId), + ); + + sortedChains = [...priorityChains, ...otherChains]; + } + + return sortedChains.map((chain) => { + return { + label: cleanChainName(chain.name), + value: String(chain.chainId), + }; + }); + }, [chainsToShow, props.priorityChains, idToChain]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return false; + } + + if (Number.isInteger(Number.parseInt(searchValue))) { + return String(chain.chainId).startsWith(searchValue); + } + return chain.name.toLowerCase().includes(searchValue.toLowerCase()); + }, + [idToChain], + ); + + const renderOption = useCallback( + (option: Option) => { + const chain = idToChain.get(Number(option.value)); + if (!chain) { + return option.label; + } + + return ( +
+ + + {cleanChainName(chain.name)} + + + {!props.disableChainId && ( + + Chain ID + {chain.chainId} + + )} +
+ ); + }, + [idToChain, props.disableChainId], + ); + + return ( + { + props.onChange(chainIds.map(Number)); + }} + placeholder={ + allChains.length === 0 ? "Loading Chains..." : "Select Chains" + } + disabled={allChains.length === 0} + overrideSearchFn={searchFn} + renderOption={renderOption} + className={props.className} + popoverContentClassName={props.popoverContentClassName} + selectedBadgeClassName={props.selectedBadgeClassName} + /> + ); +} + export function SingleNetworkSelector(props: { chainId: number | undefined; onChange: (chainId: number) => void; diff --git a/apps/playground-web/src/components/blocks/multi-select.tsx b/apps/playground-web/src/components/blocks/multi-select.tsx new file mode 100644 index 00000000000..8991e404b98 --- /dev/null +++ b/apps/playground-web/src/components/blocks/multi-select.tsx @@ -0,0 +1,331 @@ +/* eslint-disable no-restricted-syntax */ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { CheckIcon, ChevronDown, SearchIcon, XIcon } from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useShowMore } from "../../lib/useShowMore"; +import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; +import { Input } from "../ui/input"; + +interface MultiSelectProps + extends React.ButtonHTMLAttributes { + options: { + label: string; + value: string; + }[]; + + selectedValues: string[]; + onSelectedValuesChange: (value: string[]) => void; + placeholder: string; + searchPlaceholder?: string; + popoverContentClassName?: string; + selectedBadgeClassName?: string; + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + className?: string; + + overrideSearchFn?: ( + option: { value: string; label: string }, + searchTerm: string, + ) => boolean; + + renderOption?: (option: { value: string; label: string }) => React.ReactNode; +} + +export const MultiSelect = forwardRef( + ( + { + options, + onSelectedValuesChange, + placeholder, + maxCount = Number.POSITIVE_INFINITY, + className, + selectedValues, + overrideSearchFn, + renderOption, + popoverContentClassName, + selectedBadgeClassName, + searchPlaceholder, + ...props + }, + ref, + ) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + onSelectedValuesChange(newSelectedValues); + } + }, + [selectedValues, onSelectedValuesChange], + ); + + const toggleOption = useCallback( + (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + onSelectedValuesChange(newSelectedValues); + }, + [selectedValues, onSelectedValuesChange], + ); + + const handleClear = useCallback(() => { + onSelectedValuesChange([]); + }, [onSelectedValuesChange]); + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = useCallback(() => { + const newSelectedValues = selectedValues.slice(0, maxCount); + onSelectedValuesChange(newSelectedValues); + }, [selectedValues, onSelectedValuesChange, maxCount]); + + // show 50 initially and then 20 more when reaching the end + const { itemsToShow, lastItemRef } = useShowMore(50, 20); + + const optionsToShow = useMemo(() => { + const filteredOptions: { + label: string; + value: string; + }[] = []; + + const searchValLowercase = searchValue.toLowerCase(); + + for (let i = 0; i <= options.length - 1; i++) { + if (filteredOptions.length >= itemsToShow) { + break; + } + const option = options[i]; + if (!option) { + continue; + } + + if (overrideSearchFn) { + if (overrideSearchFn(option, searchValLowercase)) { + filteredOptions.push(option); + } + } else { + if (option.label.toLowerCase().includes(searchValLowercase)) { + filteredOptions.push(option); + } + } + } + + return filteredOptions; + }, [options, searchValue, itemsToShow, overrideSearchFn]); + + // scroll to top when options change + const popoverElRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + const scrollContainer = + popoverElRef.current?.querySelector("[data-scrollable]"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + }); + } + }, [searchValue]); + + return ( + + + + + setIsPopoverOpen(false)} + style={{ + width: "var(--radix-popover-trigger-width)", + maxHeight: "var(--radix-popover-content-available-height)", + }} + ref={popoverElRef} + > +
+ {/* Search */} +
+ setSearchValue(e.target.value)} + className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0" + onKeyDown={handleInputKeyDown} + /> + +
+ + + {/* List */} +
+ {optionsToShow.length === 0 && ( +
+ No results found +
+ )} + + {optionsToShow.map((option, i) => { + const isSelected = selectedValues.includes(option.value); + return ( + + ); + })} +
+
+
+
+
+ ); + }, +); + +function ClosableBadge(props: { + label: string; + onClose: () => void; + className?: string; +}) { + return ( + + {props.label} + { + e.stopPropagation(); + props.onClose(); + }} + /> + + ); +} + +MultiSelect.displayName = "MultiSelect"; diff --git a/apps/playground-web/src/lib/useShowMore.ts b/apps/playground-web/src/lib/useShowMore.ts new file mode 100644 index 00000000000..052037fb172 --- /dev/null +++ b/apps/playground-web/src/lib/useShowMore.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useCallback, useState } from "react"; + +/** + * + * @internal + */ +export function useShowMore( + initialItemsToShow: number, + itemsToAdd: number, +) { + // start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more + const [itemsToShow, setItemsToShow] = useState(initialItemsToShow); + const lastItemRef = useCallback( + (node: T) => { + if (!node) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items + } + }, + { threshold: 1 }, + ); + + observer.observe(node); + // when the node is removed from the DOM, observer will be disconnected automatically by the browser + }, + [itemsToAdd], + ); + + return { itemsToShow, lastItemRef }; +}