|
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