|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { MultiSelect } from "@/components/blocks/multi-select"; |
4 | | -import { Input } from "@/components/ui/input"; |
5 | 3 | import { cn } from "@/lib/utils"; |
6 | 4 | import { useCallback, useEffect, useMemo, useState } from "react"; |
7 | 5 | 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"; |
8 | 13 |
|
9 | 14 | interface Preset { |
10 | 15 | label: string; |
@@ -147,130 +152,102 @@ interface AggregateParameterInputProps { |
147 | 152 | } |
148 | 153 |
|
149 | 154 | export function AggregateParameterInput(props: AggregateParameterInputProps) { |
150 | | - const { field, placeholder, endpointPath, showTip } = props; |
| 155 | + const { field, placeholder, endpointPath } = props; |
151 | 156 | const { value, onChange } = field; |
| 157 | + const [searchQuery, setSearchQuery] = useState(''); |
| 158 | + const inputRef = useRef<HTMLInputElement>(null); |
| 159 | + const [isPopoverOpen, setIsPopoverOpen] = useState(false); |
152 | 160 |
|
153 | | - const presets = useMemo( |
154 | | - () => getAggregatePresets(endpointPath), |
155 | | - [endpointPath], |
156 | | - ); |
157 | | - |
| 161 | + const presets = useMemo(() => getAggregatePresets(endpointPath), [endpointPath]); |
| 162 | + |
158 | 163 | const selectedValues = useMemo(() => { |
159 | 164 | 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); |
168 | 166 | }, [value]); |
169 | 167 |
|
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); |
234 | 170 | }; |
235 | 171 |
|
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]); |
250 | 177 |
|
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" |
273 | 187 | /> |
| 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 | + )} |
274 | 251 | </div> |
275 | 252 | ); |
276 | 253 | } |
0 commit comments