Skip to content

Commit 6a35e4e

Browse files
committed
feat: enhance aggregate parameter input with multi-select presets
1 parent 03342b3 commit 6a35e4e

File tree

1 file changed

+93
-116
lines changed

1 file changed

+93
-116
lines changed

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

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

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

914
interface Preset {
1015
label: string;
@@ -147,130 +152,102 @@ interface AggregateParameterInputProps {
147152
}
148153

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

153-
const presets = useMemo(
154-
() => getAggregatePresets(endpointPath),
155-
[endpointPath],
156-
);
157-
161+
const presets = useMemo(() => getAggregatePresets(endpointPath), [endpointPath]);
162+
158163
const selectedValues = useMemo(() => {
159164
if (!value) return [];
160-
return Array.from(
161-
new Set(
162-
String(value)
163-
.split(",")
164-
.map((v) => v.trim()) // remove leading / trailing spaces
165-
.filter(Boolean),
166-
),
167-
);
165+
return String(value).split(',').filter(Boolean);
168166
}, [value]);
169167

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

236-
return (
237-
<div className="w-full">
238-
{/* Editable formula text field */}
239-
<div className="relative">
240-
<Input
241-
value={manualInput}
242-
onChange={handleManualInputChange}
243-
placeholder={placeholder}
244-
className={cn(
245-
"h-auto truncate rounded-none border-0 bg-transparent py-3 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
246-
showTip && "lg:pr-10",
247-
)}
248-
/>
249-
</div>
172+
const handlePresetSelect = useCallback((preset: { value: string; label: string }) => {
173+
const newValue = value ? `${value}, ${preset.value}` : preset.value;
174+
onChange({ target: { value: newValue } });
175+
inputRef.current?.focus();
176+
}, [value, onChange]);
250177

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

0 commit comments

Comments
 (0)