|
1 | 1 | "use client"; |
2 | 2 |
|
| 3 | +import { MultiSelect } from "@/components/blocks/multi-select"; |
| 4 | +import { Input } from "@/components/ui/input"; |
3 | 5 | import { cn } from "@/lib/utils"; |
4 | 6 | import { useCallback, useEffect, useMemo, useState } from "react"; |
5 | 7 | 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"; |
13 | 8 |
|
14 | 9 | interface Preset { |
15 | 10 | label: string; |
@@ -152,102 +147,123 @@ interface AggregateParameterInputProps { |
152 | 147 | } |
153 | 148 |
|
154 | 149 | export function AggregateParameterInput(props: AggregateParameterInputProps) { |
155 | | - const { field, placeholder, endpointPath } = props; |
| 150 | + const { field, placeholder, endpointPath, showTip } = props; |
156 | 151 | const { value, onChange } = field; |
157 | | - const [searchQuery, setSearchQuery] = useState(''); |
158 | | - const inputRef = useRef<HTMLInputElement>(null); |
159 | | - const [isPopoverOpen, setIsPopoverOpen] = useState(false); |
160 | 152 |
|
161 | | - const presets = useMemo(() => getAggregatePresets(endpointPath), [endpointPath]); |
162 | | - |
| 153 | + const presets = useMemo( |
| 154 | + () => getAggregatePresets(endpointPath), |
| 155 | + [endpointPath], |
| 156 | + ); |
| 157 | + |
163 | 158 | const selectedValues = useMemo(() => { |
164 | 159 | if (!value) return []; |
165 | | - return String(value).split(',').filter(Boolean); |
| 160 | + return String(value).split(",").filter(Boolean); |
166 | 161 | }, [value]); |
167 | 162 |
|
168 | | - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
169 | | - onChange(e); |
170 | | - }; |
| 163 | + const handlePresetChange = useCallback( |
| 164 | + (values: string[]) => { |
| 165 | + onChange({ target: { value: values.join(",") } }); |
| 166 | + }, |
| 167 | + [onChange], |
| 168 | + ); |
171 | 169 |
|
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]); |
| 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 | + 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 | + }; |
177 | 228 |
|
178 | 229 | 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" |
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 | | - ))} |
| 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-border border-t-2 border-dashed", |
| 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 w-full items-center justify-between"> |
| 260 | + <span className="truncate">{option.label}</span> |
| 261 | + <span className="ml-2 truncate font-mono text-muted-foreground text-xs"> |
| 262 | + {option.value} |
| 263 | + </span> |
234 | 264 | </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 | | - )} |
| 265 | + )} |
| 266 | + /> |
251 | 267 | </div> |
252 | 268 | ); |
253 | 269 | } |
0 commit comments