|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { cn } from "@/lib/utils"; |
4 | | -import type { ControllerRenderProps } from "react-hook-form"; |
5 | 3 | import { MultiSelect } from "@/components/blocks/multi-select"; |
6 | | -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
7 | 4 | 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"; |
12 | 8 |
|
13 | 9 | interface Preset { |
14 | 10 | label: string; |
@@ -151,102 +147,123 @@ interface AggregateParameterInputProps { |
151 | 147 | } |
152 | 148 |
|
153 | 149 | export function AggregateParameterInput(props: AggregateParameterInputProps) { |
154 | | - const { field, placeholder, endpointPath } = props; |
| 150 | + const { field, placeholder, endpointPath, showTip } = props; |
155 | 151 | const { value, onChange } = field; |
156 | | - const [searchQuery, setSearchQuery] = useState(''); |
157 | | - const inputRef = useRef<HTMLInputElement>(null); |
158 | | - const [isPopoverOpen, setIsPopoverOpen] = useState(false); |
159 | 152 |
|
160 | | - const presets = useMemo(() => getAggregatePresets(endpointPath), [endpointPath]); |
161 | | - |
| 153 | + const presets = useMemo( |
| 154 | + () => getAggregatePresets(endpointPath), |
| 155 | + [endpointPath], |
| 156 | + ); |
| 157 | + |
162 | 158 | const selectedValues = useMemo(() => { |
163 | 159 | if (!value) return []; |
164 | | - return String(value).split(',').filter(Boolean); |
| 160 | + return String(value).split(",").filter(Boolean); |
165 | 161 | }, [value]); |
166 | 162 |
|
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 | + ); |
170 | 169 |
|
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 | + }; |
176 | 228 |
|
177 | 229 | 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> |
233 | 264 | </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 | + /> |
250 | 267 | </div> |
251 | 268 | ); |
252 | 269 | } |
0 commit comments