Skip to content

Commit 1ec21f8

Browse files
committed
refactor: improve aggregate parameter input with MultiSelect
1 parent a82e7b1 commit 1ec21f8

File tree

2 files changed

+120
-96
lines changed

2 files changed

+120
-96
lines changed

apps/playground-web/src/app/insight/[blueprint_slug]/aggregate-parameter-input.client.tsx

Lines changed: 110 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
"use client";
22

3-
import { cn } from "@/lib/utils";
4-
import type { ControllerRenderProps } from "react-hook-form";
53
import { MultiSelect } from "@/components/blocks/multi-select";
6-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
74
import { Input } from "@/components/ui/input";
8-
import { Badge } from "@/components/ui/badge";
9-
import { Button } from "@/components/ui/button";
10-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
11-
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
5+
import { cn } from "@/lib/utils";
6+
import { useCallback, useEffect, useMemo, useState } from "react";
7+
import type { ControllerRenderProps } from "react-hook-form";
128

139
interface Preset {
1410
label: string;
@@ -151,102 +147,123 @@ interface AggregateParameterInputProps {
151147
}
152148

153149
export function AggregateParameterInput(props: AggregateParameterInputProps) {
154-
const { field, placeholder, endpointPath } = props;
150+
const { field, placeholder, endpointPath, showTip } = props;
155151
const { value, onChange } = field;
156-
const [searchQuery, setSearchQuery] = useState('');
157-
const inputRef = useRef<HTMLInputElement>(null);
158-
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
159152

160-
const presets = useMemo(() => getAggregatePresets(endpointPath), [endpointPath]);
161-
153+
const presets = useMemo(
154+
() => getAggregatePresets(endpointPath),
155+
[endpointPath],
156+
);
157+
162158
const selectedValues = useMemo(() => {
163159
if (!value) return [];
164-
return String(value).split(',').filter(Boolean);
160+
return String(value).split(",").filter(Boolean);
165161
}, [value]);
166162

167-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
168-
onChange(e);
169-
};
163+
const handlePresetChange = useCallback(
164+
(values: string[]) => {
165+
onChange({ target: { value: values.join(",") } });
166+
},
167+
[onChange],
168+
);
170169

171-
const handlePresetSelect = useCallback((preset: { value: string; label: string }) => {
172-
const newValue = value ? `${value}, ${preset.value}` : preset.value;
173-
onChange({ target: { value: newValue } });
174-
inputRef.current?.focus();
175-
}, [value, onChange]);
170+
// Custom search function for the MultiSelect
171+
const searchFunction = useCallback(
172+
(option: { value: string; label: string }, searchTerm: string) => {
173+
if (!searchTerm) return true;
174+
const query = searchTerm.toLowerCase();
175+
return (
176+
option.label.toLowerCase().includes(query) ||
177+
option.value.toLowerCase().includes(query)
178+
);
179+
},
180+
[],
181+
);
182+
183+
// Get display values for the selected items
184+
const getDisplayValue = useCallback(
185+
(value: string) => {
186+
const preset = presets.find((p) => p.value === value);
187+
return preset ? preset.label : value;
188+
},
189+
[presets],
190+
);
191+
192+
// Format selected values for display in the MultiSelect
193+
useMemo(() => {
194+
return selectedValues.map((value) => {
195+
const preset = presets.find((p) => p.value === value);
196+
return {
197+
label: preset?.label || value,
198+
value,
199+
};
200+
});
201+
}, [selectedValues, presets]);
202+
203+
// State for the manual input text
204+
const [manualInput, setManualInput] = useState("");
205+
206+
// Update manual input when selected values change
207+
useEffect(() => {
208+
if (selectedValues.length === 0) {
209+
setManualInput("");
210+
} else {
211+
setManualInput(selectedValues.join(", "));
212+
}
213+
}, [selectedValues]);
214+
215+
// Handle manual input changes
216+
const handleManualInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
217+
const value = e.target.value;
218+
setManualInput(value);
219+
220+
// Update selected values by splitting on commas and trimming whitespace
221+
const newValues = value
222+
.split(",")
223+
.map((v) => v.trim())
224+
.filter(Boolean);
225+
226+
onChange({ target: { value: newValues.join(",") } });
227+
};
176228

177229
return (
178-
<div className="w-full space-y-2">
179-
{/* Main input field */}
180-
<Input
181-
ref={inputRef}
182-
value={value || ''}
183-
onChange={handleInputChange}
184-
placeholder={placeholder || "Enter aggregation formula..."}
185-
className="w-full font-mono text-sm"
186-
/>
187-
188-
{/* Preset selector */}
189-
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
190-
<PopoverTrigger asChild>
191-
<Button
192-
variant="outline"
193-
size="sm"
194-
className="w-full justify-between text-muted-foreground"
195-
type="button"
196-
>
197-
<span>Select from presets</span>
198-
<ChevronDownIcon className="h-4 w-4" />
199-
</Button>
200-
</PopoverTrigger>
201-
<PopoverContent className="w-[500px] p-0" align="start">
202-
<div className="p-2 border-b">
203-
<div className="relative">
204-
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
205-
<Input
206-
value={searchQuery}
207-
onChange={(e) => setSearchQuery(e.target.value)}
208-
placeholder="Search aggregations..."
209-
className="pl-8 h-9"
210-
/>
211-
</div>
212-
</div>
213-
<div className="max-h-[300px] overflow-auto p-1">
214-
{presets
215-
.filter(preset =>
216-
!searchQuery ||
217-
preset.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
218-
preset.value.toLowerCase().includes(searchQuery.toLowerCase())
219-
)
220-
.map((preset) => (
221-
<button
222-
key={preset.value}
223-
className="w-full text-left p-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md flex items-center justify-between"
224-
onClick={() => handlePresetSelect(preset)}
225-
type="button"
226-
>
227-
<span>{preset.label}</span>
228-
<span className="text-xs text-muted-foreground font-mono ml-2">
229-
{preset.value}
230-
</span>
231-
</button>
232-
))}
230+
<div className="w-full">
231+
{/* Editable formula text field */}
232+
<div className="relative">
233+
<Input
234+
value={manualInput}
235+
onChange={handleManualInputChange}
236+
placeholder={placeholder}
237+
className={cn(
238+
"h-auto truncate rounded-none border-0 bg-transparent py-3 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
239+
showTip && "lg:pr-10",
240+
)}
241+
/>
242+
</div>
243+
244+
{/* MultiSelect for choosing aggregations */}
245+
<MultiSelect
246+
options={presets}
247+
selectedValues={selectedValues}
248+
onSelectedValuesChange={handlePresetChange}
249+
placeholder="Select presets (optional)"
250+
searchPlaceholder="Search aggregation presets"
251+
className={cn(
252+
"rounded-none border-0 border-t-2 border-dashed border-border",
253+
"hover:bg-inherit",
254+
)}
255+
popoverContentClassName="min-w-[calc(100vw-20px)] lg:min-w-[500px]"
256+
selectedBadgeClassName="font-normal"
257+
overrideSearchFn={searchFunction}
258+
renderOption={(option) => (
259+
<div className="flex items-center justify-between w-full">
260+
<span className="truncate">{option.label}</span>
261+
<span className="ml-2 text-xs text-muted-foreground font-mono truncate">
262+
{option.value}
263+
</span>
233264
</div>
234-
</PopoverContent>
235-
</Popover>
236-
237-
{/* Selected presets as badges */}
238-
{selectedValues.length > 0 && (
239-
<div className="flex flex-wrap gap-1">
240-
{selectedValues.map((val) => {
241-
const preset = presets.find(p => p.value === val);
242-
return (
243-
<Badge key={val} variant="secondary" className="font-normal">
244-
{preset?.label || val}
245-
</Badge>
246-
);
247-
})}
248-
</div>
249-
)}
265+
)}
266+
/>
250267
</div>
251268
);
252269
}

apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,9 @@ function ParameterSection(props: {
527527
<div className="flex flex-col gap-2">
528528
{description && (
529529
<p className="text-foreground">
530-
{description}
530+
{param.name === "aggregate"
531+
? "Aggregation(s). You can type in multiple, separated by a comma, or select from the presets"
532+
: description}
531533
</p>
532534
)}
533535

@@ -536,7 +538,9 @@ function ParameterSection(props: {
536538
<p className="mb-1 text-muted-foreground">
537539
Example:{" "}
538540
<span className="font-mono">
539-
{exampleToShow}
541+
{param.name === "aggregate"
542+
? "count() AS count_all, countDistinct(address) AS unique_addresses"
543+
: exampleToShow}
540544
</span>
541545
</p>
542546
</div>
@@ -547,7 +551,10 @@ function ParameterSection(props: {
547551
<Button
548552
asChild
549553
variant="ghost"
550-
className="-translate-y-1/2 absolute top-1/2 right-2 hidden h-auto w-auto p-1.5 text-muted-foreground opacity-50 hover:opacity-100 lg:flex"
554+
className={cn(
555+
"absolute top-1/2 -translate-y-1/2 right-2 hidden h-auto w-auto p-1.5 text-muted-foreground opacity-50 hover:opacity-100 lg:flex",
556+
param.name === "aggregate" && "top-[21px]",
557+
)}
551558
>
552559
<div>
553560
<InfoIcon className="size-4" />

0 commit comments

Comments
 (0)