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 React, { useState } from 'react';
import { Input } from '@/components/ui/inputs/Input';
import { Popover } from '@/components/ui/popover/Popover';
import { cn } from '@/lib/utils';
interface DashboardFiltersProps {
@ -32,6 +33,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterOperators, setFilterOperators] = useState<Record<string, string>>({});
const [customMode, setCustomMode] = useState<Record<string, boolean>>({});
const [multiSelectValues, setMultiSelectValues] = useState<Record<string, string[]>>({});
const hasCommonFilters = commonFilters && commonFilters.length > 0;
@ -49,24 +51,58 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
setFilterValues(newFilterValues);
// 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) => {
setFilterOperators({ ...filterOperators, [key]: op });
};
const processFilterValues = (
currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string>
currentFilterOperators: Record<string, string>,
currentMultiSelectValues: Record<string, string[]>
) => {
// Convert values to appropriate types
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]) => {
const filter = commonFilters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return;
// Skip if already processed as multi-select
if (filterKey in typedValues) return;
let parsedValue: unknown;
// Parse values based on filter type
@ -99,7 +135,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
};
const handleBlur = () => {
processFilterValues(filterValues, filterOperators);
processFilterValues(filterValues, filterOperators, multiSelectValues);
};
return (
@ -110,6 +146,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
{commonFilters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key];
const isInListMode = filter.mode === 'in_list';
return (
<div key={filter.key} className="flex items-center gap-2">
@ -130,22 +167,107 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
))}
</select>
)}
{hasEnum && !isCustomMode ? (
<select
id={`dashboard-${filter.key}`}
value={filterValues[filter.key] || ''}
onChange={(e) => handleEnumChange(filter.key, e.target.value)}
className="h-8 w-48 rounded-md border border-input bg-background px-2 text-sm"
{hasEnum && !isCustomMode && isInListMode ? (
<Popover
content={
<div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
{filter.validate!.enum!.map((enumValue) => {
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"
>
<option value="">Select value...</option>
{filter.validate!.enum!.map((enumValue) => (
<option key={String(enumValue)} value={String(enumValue)}>
{String(enumValue)}
</option>
))}
<option value={CUSTOM_VALUE_KEY}>Custom...</option>
</select>
<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"
>
<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">
{(multiSelectValues[filter.key] || []).length > 0
? `${(multiSelectValues[filter.key] || []).length} selected`
: '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>
) : (
<div className="flex items-center gap-2">
<Input
id={`dashboard-${filter.key}`}
type="text"
@ -155,6 +277,19 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
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>
);

View File

@ -1,6 +1,7 @@
import type { MetricFilter } from '@buster/server-shared/metrics';
import React, { useState } from 'react';
import { Input } from '@/components/ui/inputs/Input';
import { Popover } from '@/components/ui/popover/Popover';
import { cn } from '@/lib/utils';
interface MetricFiltersProps {
@ -28,6 +29,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterOperators, setFilterOperators] = useState<Record<string, string>>({});
const [customMode, setCustomMode] = useState<Record<string, boolean>>({});
const [multiSelectValues, setMultiSelectValues] = useState<Record<string, string[]>>({});
if (!filters || filters.length === 0) {
return null;
@ -47,24 +49,58 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
setFilterValues(newFilterValues);
// 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) => {
setFilterOperators({ ...filterOperators, [key]: op });
};
const processFilterValues = (
currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string>
currentFilterOperators: Record<string, string>,
currentMultiSelectValues: Record<string, string[]>
) => {
// Convert values to appropriate types
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]) => {
const filter = filters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return;
// Skip if already processed as multi-select
if (filterKey in typedValues) return;
let parsedValue: unknown;
// Parse values based on filter type
@ -97,7 +133,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
};
const handleBlur = () => {
processFilterValues(filterValues, filterOperators);
processFilterValues(filterValues, filterOperators, multiSelectValues);
};
return (
@ -105,7 +141,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
{filters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key];
const showInput = !hasEnum || isCustomMode;
const isInListMode = filter.mode === 'in_list';
return (
<div key={filter.key} className="flex items-center gap-2">
@ -126,22 +162,107 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
))}
</select>
)}
{hasEnum && !isCustomMode ? (
<select
id={filter.key}
value={filterValues[filter.key] || ''}
onChange={(e) => handleEnumChange(filter.key, e.target.value)}
className="h-8 w-48 rounded-md border border-input bg-background px-2 text-sm"
{hasEnum && !isCustomMode && isInListMode ? (
<Popover
content={
<div className="flex max-h-64 w-48 flex-col gap-1 overflow-y-auto p-2">
{filter.validate!.enum!.map((enumValue) => {
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"
>
<option value="">Select value...</option>
{filter.validate!.enum!.map((enumValue) => (
<option key={String(enumValue)} value={String(enumValue)}>
{String(enumValue)}
</option>
))}
<option value={CUSTOM_VALUE_KEY}>Custom...</option>
</select>
<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"
>
<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">
{(multiSelectValues[filter.key] || []).length > 0
? `${(multiSelectValues[filter.key] || []).length} selected`
: '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>
) : (
<div className="flex items-center gap-2">
<Input
id={filter.key}
type="text"
@ -151,6 +272,19 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
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>
);