improved enum drop downs + multi select

This commit is contained in:
jacob-buster 2025-10-06 14:44:54 -06:00
parent 95a5178eb6
commit 9f495339d9
2 changed files with 322 additions and 53 deletions

View File

@ -1,6 +1,7 @@
import type { MetricFilter } from '@buster/server-shared/metrics'; import type { MetricFilter } from '@buster/server-shared/metrics';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Input } from '@/components/ui/inputs/Input'; import { Input } from '@/components/ui/inputs/Input';
import { Popover } from '@/components/ui/popover/Popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface DashboardFiltersProps { interface DashboardFiltersProps {
@ -32,6 +33,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
const [filterValues, setFilterValues] = useState<Record<string, string>>({}); const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterOperators, setFilterOperators] = useState<Record<string, string>>({}); const [filterOperators, setFilterOperators] = useState<Record<string, string>>({});
const [customMode, setCustomMode] = useState<Record<string, boolean>>({}); const [customMode, setCustomMode] = useState<Record<string, boolean>>({});
const [multiSelectValues, setMultiSelectValues] = useState<Record<string, string[]>>({});
const hasCommonFilters = commonFilters && commonFilters.length > 0; const hasCommonFilters = commonFilters && commonFilters.length > 0;
@ -49,24 +51,58 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
setFilterValues(newFilterValues); setFilterValues(newFilterValues);
// Immediately process the enum selection // Immediately process the enum selection
processFilterValues(newFilterValues, filterOperators); processFilterValues(newFilterValues, filterOperators, multiSelectValues);
} }
}; };
const handleMultiSelectToggle = (key: string, value: string) => {
if (value === CUSTOM_VALUE_KEY) {
setCustomMode({ ...customMode, [key]: true });
setMultiSelectValues({ ...multiSelectValues, [key]: [] });
return;
}
const currentValues = multiSelectValues[key] || [];
const newValues = currentValues.includes(value)
? currentValues.filter((v) => v !== value)
: [...currentValues, value];
const newMultiSelectValues = { ...multiSelectValues, [key]: newValues };
setMultiSelectValues(newMultiSelectValues);
// Immediately process the multi-select
processFilterValues(filterValues, filterOperators, newMultiSelectValues);
};
const handleOperatorChange = (key: string, op: string) => { const handleOperatorChange = (key: string, op: string) => {
setFilterOperators({ ...filterOperators, [key]: op }); setFilterOperators({ ...filterOperators, [key]: op });
}; };
const processFilterValues = ( const processFilterValues = (
currentFilterValues: Record<string, string>, currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string> currentFilterOperators: Record<string, string>,
currentMultiSelectValues: Record<string, string[]>
) => { ) => {
// Convert values to appropriate types // Convert values to appropriate types
const typedValues: Record<string, unknown> = {}; const typedValues: Record<string, unknown> = {};
// Process multi-select values first (for in_list mode)
Object.entries(currentMultiSelectValues).forEach(([filterKey, selectedValues]) => {
const filter = commonFilters.find((f) => f.key === filterKey);
if (!filter || !selectedValues || selectedValues.length === 0) return;
// Multi-select is always an array
typedValues[filterKey] = selectedValues;
});
// Process regular filter values
Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => { Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => {
const filter = commonFilters.find((f) => f.key === filterKey); const filter = commonFilters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return; if (!filter || !filterValue) return;
// Skip if already processed as multi-select
if (filterKey in typedValues) return;
let parsedValue: unknown; let parsedValue: unknown;
// Parse values based on filter type // Parse values based on filter type
@ -99,7 +135,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
}; };
const handleBlur = () => { const handleBlur = () => {
processFilterValues(filterValues, filterOperators); processFilterValues(filterValues, filterOperators, multiSelectValues);
}; };
return ( return (
@ -110,6 +146,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
{commonFilters.map((filter) => { {commonFilters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0; const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key]; const isCustomMode = customMode[filter.key];
const isInListMode = filter.mode === 'in_list';
return ( return (
<div key={filter.key} className="flex items-center gap-2"> <div key={filter.key} className="flex items-center gap-2">
@ -130,31 +167,129 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
))} ))}
</select> </select>
)} )}
{hasEnum && !isCustomMode ? ( {hasEnum && !isCustomMode && isInListMode ? (
<select <Popover
id={`dashboard-${filter.key}`} content={
value={filterValues[filter.key] || ''} <div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
onChange={(e) => handleEnumChange(filter.key, e.target.value)} {filter.validate!.enum!.map((enumValue) => {
className="h-8 w-48 rounded-md border border-input bg-background px-2 text-sm" const isSelected = (multiSelectValues[filter.key] || []).includes(String(enumValue));
return (
<label
key={String(enumValue)}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent"
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMultiSelectToggle(filter.key, String(enumValue))}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">{String(enumValue)}</span>
</label>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={() => handleMultiSelectToggle(filter.key, CUSTOM_VALUE_KEY)}
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
>
Custom...
</button>
</div>
</div>
}
align="start"
> >
<option value="">Select value...</option> <button
{filter.validate!.enum!.map((enumValue) => ( type="button"
<option key={String(enumValue)} value={String(enumValue)}> className="flex h-8 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm hover:bg-accent"
{String(enumValue)} >
</option> <span className="truncate">
))} {(multiSelectValues[filter.key] || []).length > 0
<option value={CUSTOM_VALUE_KEY}>Custom...</option> ? `${(multiSelectValues[filter.key] || []).length} selected`
</select> : 'Select values...'}
</span>
<span className="ml-2"></span>
</button>
</Popover>
) : hasEnum && !isCustomMode ? (
<Popover
content={
<div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
<label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent">
<input
type="radio"
name={`dashboard-filter-${filter.key}`}
checked={!filterValues[filter.key]}
onChange={() => handleEnumChange(filter.key, '')}
className="h-4 w-4"
/>
<span className="text-sm text-muted-foreground">None</span>
</label>
{filter.validate!.enum!.map((enumValue) => {
const isSelected = filterValues[filter.key] === String(enumValue);
return (
<label
key={String(enumValue)}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent"
>
<input
type="radio"
name={`dashboard-filter-${filter.key}`}
checked={isSelected}
onChange={() => handleEnumChange(filter.key, String(enumValue))}
className="h-4 w-4"
/>
<span className="text-sm">{String(enumValue)}</span>
</label>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={() => handleEnumChange(filter.key, CUSTOM_VALUE_KEY)}
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
>
Custom...
</button>
</div>
</div>
}
align="start"
>
<button
type="button"
className="flex h-8 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm hover:bg-accent"
>
<span className="truncate">
{filterValues[filter.key] || 'Select value...'}
</span>
<span className="ml-2"></span>
</button>
</Popover>
) : ( ) : (
<Input <div className="flex items-center gap-2">
id={`dashboard-${filter.key}`} <Input
type="text" id={`dashboard-${filter.key}`}
value={filterValues[filter.key] || ''} type="text"
onChange={(e) => handleInputChange(filter.key, e.target.value)} value={filterValues[filter.key] || ''}
onBlur={handleBlur} onChange={(e) => handleInputChange(filter.key, e.target.value)}
placeholder={getPlaceholder(filter)} onBlur={handleBlur}
className="h-8 w-48" placeholder={getPlaceholder(filter)}
/> className="h-8 w-48"
/>
{hasEnum && isCustomMode && (
<button
type="button"
onClick={() => {
setCustomMode({ ...customMode, [filter.key]: false });
setFilterValues({ ...filterValues, [filter.key]: '' });
}}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Back to options
</button>
)}
</div>
)} )}
</div> </div>
); );

View File

@ -1,6 +1,7 @@
import type { MetricFilter } from '@buster/server-shared/metrics'; import type { MetricFilter } from '@buster/server-shared/metrics';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Input } from '@/components/ui/inputs/Input'; import { Input } from '@/components/ui/inputs/Input';
import { Popover } from '@/components/ui/popover/Popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface MetricFiltersProps { interface MetricFiltersProps {
@ -28,6 +29,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
const [filterValues, setFilterValues] = useState<Record<string, string>>({}); const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterOperators, setFilterOperators] = useState<Record<string, string>>({}); const [filterOperators, setFilterOperators] = useState<Record<string, string>>({});
const [customMode, setCustomMode] = useState<Record<string, boolean>>({}); const [customMode, setCustomMode] = useState<Record<string, boolean>>({});
const [multiSelectValues, setMultiSelectValues] = useState<Record<string, string[]>>({});
if (!filters || filters.length === 0) { if (!filters || filters.length === 0) {
return null; return null;
@ -47,24 +49,58 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
setFilterValues(newFilterValues); setFilterValues(newFilterValues);
// Immediately process the enum selection // Immediately process the enum selection
processFilterValues(newFilterValues, filterOperators); processFilterValues(newFilterValues, filterOperators, multiSelectValues);
} }
}; };
const handleMultiSelectToggle = (key: string, value: string) => {
if (value === CUSTOM_VALUE_KEY) {
setCustomMode({ ...customMode, [key]: true });
setMultiSelectValues({ ...multiSelectValues, [key]: [] });
return;
}
const currentValues = multiSelectValues[key] || [];
const newValues = currentValues.includes(value)
? currentValues.filter((v) => v !== value)
: [...currentValues, value];
const newMultiSelectValues = { ...multiSelectValues, [key]: newValues };
setMultiSelectValues(newMultiSelectValues);
// Immediately process the multi-select
processFilterValues(filterValues, filterOperators, newMultiSelectValues);
};
const handleOperatorChange = (key: string, op: string) => { const handleOperatorChange = (key: string, op: string) => {
setFilterOperators({ ...filterOperators, [key]: op }); setFilterOperators({ ...filterOperators, [key]: op });
}; };
const processFilterValues = ( const processFilterValues = (
currentFilterValues: Record<string, string>, currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string> currentFilterOperators: Record<string, string>,
currentMultiSelectValues: Record<string, string[]>
) => { ) => {
// Convert values to appropriate types // Convert values to appropriate types
const typedValues: Record<string, unknown> = {}; const typedValues: Record<string, unknown> = {};
// Process multi-select values first (for in_list mode)
Object.entries(currentMultiSelectValues).forEach(([filterKey, selectedValues]) => {
const filter = filters.find((f) => f.key === filterKey);
if (!filter || !selectedValues || selectedValues.length === 0) return;
// Multi-select is always an array
typedValues[filterKey] = selectedValues;
});
// Process regular filter values
Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => { Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => {
const filter = filters.find((f) => f.key === filterKey); const filter = filters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return; if (!filter || !filterValue) return;
// Skip if already processed as multi-select
if (filterKey in typedValues) return;
let parsedValue: unknown; let parsedValue: unknown;
// Parse values based on filter type // Parse values based on filter type
@ -97,7 +133,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
}; };
const handleBlur = () => { const handleBlur = () => {
processFilterValues(filterValues, filterOperators); processFilterValues(filterValues, filterOperators, multiSelectValues);
}; };
return ( return (
@ -105,7 +141,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
{filters.map((filter) => { {filters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0; const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key]; const isCustomMode = customMode[filter.key];
const showInput = !hasEnum || isCustomMode; const isInListMode = filter.mode === 'in_list';
return ( return (
<div key={filter.key} className="flex items-center gap-2"> <div key={filter.key} className="flex items-center gap-2">
@ -126,31 +162,129 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
))} ))}
</select> </select>
)} )}
{hasEnum && !isCustomMode ? ( {hasEnum && !isCustomMode && isInListMode ? (
<select <Popover
id={filter.key} content={
value={filterValues[filter.key] || ''} <div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
onChange={(e) => handleEnumChange(filter.key, e.target.value)} {filter.validate!.enum!.map((enumValue) => {
className="h-8 w-48 rounded-md border border-input bg-background px-2 text-sm" const isSelected = (multiSelectValues[filter.key] || []).includes(String(enumValue));
return (
<label
key={String(enumValue)}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent"
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMultiSelectToggle(filter.key, String(enumValue))}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">{String(enumValue)}</span>
</label>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={() => handleMultiSelectToggle(filter.key, CUSTOM_VALUE_KEY)}
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
>
Custom...
</button>
</div>
</div>
}
align="start"
> >
<option value="">Select value...</option> <button
{filter.validate!.enum!.map((enumValue) => ( type="button"
<option key={String(enumValue)} value={String(enumValue)}> className="flex h-8 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm hover:bg-accent"
{String(enumValue)} >
</option> <span className="truncate">
))} {(multiSelectValues[filter.key] || []).length > 0
<option value={CUSTOM_VALUE_KEY}>Custom...</option> ? `${(multiSelectValues[filter.key] || []).length} selected`
</select> : 'Select values...'}
</span>
<span className="ml-2"></span>
</button>
</Popover>
) : hasEnum && !isCustomMode ? (
<Popover
content={
<div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
<label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent">
<input
type="radio"
name={`filter-${filter.key}`}
checked={!filterValues[filter.key]}
onChange={() => handleEnumChange(filter.key, '')}
className="h-4 w-4"
/>
<span className="text-sm text-muted-foreground">None</span>
</label>
{filter.validate!.enum!.map((enumValue) => {
const isSelected = filterValues[filter.key] === String(enumValue);
return (
<label
key={String(enumValue)}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-accent"
>
<input
type="radio"
name={`filter-${filter.key}`}
checked={isSelected}
onChange={() => handleEnumChange(filter.key, String(enumValue))}
className="h-4 w-4"
/>
<span className="text-sm">{String(enumValue)}</span>
</label>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={() => handleEnumChange(filter.key, CUSTOM_VALUE_KEY)}
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
>
Custom...
</button>
</div>
</div>
}
align="start"
>
<button
type="button"
className="flex h-8 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm hover:bg-accent"
>
<span className="truncate">
{filterValues[filter.key] || 'Select value...'}
</span>
<span className="ml-2"></span>
</button>
</Popover>
) : ( ) : (
<Input <div className="flex items-center gap-2">
id={filter.key} <Input
type="text" id={filter.key}
value={filterValues[filter.key] || ''} type="text"
onChange={(e) => handleInputChange(filter.key, e.target.value)} value={filterValues[filter.key] || ''}
onBlur={handleBlur} onChange={(e) => handleInputChange(filter.key, e.target.value)}
placeholder={getPlaceholder(filter)} onBlur={handleBlur}
className="h-8 w-48" placeholder={getPlaceholder(filter)}
/> className="h-8 w-48"
/>
{hasEnum && isCustomMode && (
<button
type="button"
onClick={() => {
setCustomMode({ ...customMode, [filter.key]: false });
setFilterValues({ ...filterValues, [filter.key]: '' });
}}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Back to options
</button>
)}
</div>
)} )}
</div> </div>
); );