Adding Enum drop downs and the ability to hide metric level filters

This commit is contained in:
jacob-buster 2025-10-06 14:28:14 -06:00
parent 50290f4d2c
commit 95a5178eb6
5 changed files with 209 additions and 78 deletions

View File

@ -6,6 +6,8 @@ import { cn } from '@/lib/utils';
interface DashboardFiltersProps {
commonFilters: MetricFilter[];
onFilterValuesChange: (filterValues: Record<string, unknown>) => void;
showMetricFilters: boolean;
onToggleMetricFilters: () => void;
className?: string;
}
@ -18,30 +20,50 @@ function canUseOperator(filter: MetricFilter): boolean {
return MODES_SUPPORTING_OPERATORS.has(filter.mode);
}
const CUSTOM_VALUE_KEY = '__custom__';
export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
commonFilters,
onFilterValuesChange,
showMetricFilters,
onToggleMetricFilters,
className,
}) => {
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterOperators, setFilterOperators] = useState<Record<string, string>>({});
const [customMode, setCustomMode] = useState<Record<string, boolean>>({});
if (!commonFilters || commonFilters.length === 0) {
return null;
}
const hasCommonFilters = commonFilters && commonFilters.length > 0;
const handleInputChange = (key: string, value: string) => {
setFilterValues({ ...filterValues, [key]: value });
};
const handleEnumChange = (key: string, value: string) => {
if (value === CUSTOM_VALUE_KEY) {
setCustomMode({ ...customMode, [key]: true });
setFilterValues({ ...filterValues, [key]: '' });
} else {
const newFilterValues = { ...filterValues, [key]: value };
setCustomMode({ ...customMode, [key]: false });
setFilterValues(newFilterValues);
// Immediately process the enum selection
processFilterValues(newFilterValues, filterOperators);
}
};
const handleOperatorChange = (key: string, op: string) => {
setFilterOperators({ ...filterOperators, [key]: op });
};
const handleBlur = () => {
const processFilterValues = (
currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string>
) => {
// Convert values to appropriate types
const typedValues: Record<string, unknown> = {};
Object.entries(filterValues).forEach(([filterKey, filterValue]) => {
Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => {
const filter = commonFilters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return;
@ -63,7 +85,7 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
}
// Check if we should include operator override
const operator = filterOperators[filterKey];
const operator = currentFilterOperators[filterKey];
const shouldIncludeOperator = operator && canUseOperator(filter);
if (shouldIncludeOperator) {
@ -76,39 +98,78 @@ export const DashboardFilters: React.FC<DashboardFiltersProps> = ({
onFilterValuesChange(typedValues);
};
const handleBlur = () => {
processFilterValues(filterValues, filterOperators);
};
return (
<div className={cn('bg-muted/50 border-border flex flex-wrap gap-3 border-b p-4', className)}>
<div className="text-sm font-semibold mr-2">Dashboard Filters:</div>
{commonFilters.map((filter) => (
<div key={filter.key} className="flex items-center gap-2">
<label htmlFor={`dashboard-${filter.key}`} className="text-muted-foreground text-sm font-medium">
{filter.key}:
</label>
{canUseOperator(filter) && (
<select
value={filterOperators[filter.key] || filter.op || '='}
onChange={(e) => handleOperatorChange(filter.key, e.target.value)}
onBlur={handleBlur}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{COMPARISON_OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
)}
<Input
id={`dashboard-${filter.key}`}
type="text"
value={filterValues[filter.key] || ''}
onChange={(e) => handleInputChange(filter.key, e.target.value)}
onBlur={handleBlur}
placeholder={getPlaceholder(filter)}
className="h-8 w-48"
/>
</div>
))}
<div className={cn('bg-muted/50 border-border flex flex-wrap items-center gap-3 border-b p-4', className)}>
{hasCommonFilters && (
<>
<div className="text-sm font-semibold mr-2">Dashboard Filters:</div>
{commonFilters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key];
return (
<div key={filter.key} className="flex items-center gap-2">
<label htmlFor={`dashboard-${filter.key}`} className="text-muted-foreground text-sm font-medium">
{filter.key}:
</label>
{canUseOperator(filter) && (
<select
value={filterOperators[filter.key] || filter.op || '='}
onChange={(e) => handleOperatorChange(filter.key, e.target.value)}
onBlur={handleBlur}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{COMPARISON_OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</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"
>
<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
id={`dashboard-${filter.key}`}
type="text"
value={filterValues[filter.key] || ''}
onChange={(e) => handleInputChange(filter.key, e.target.value)}
onBlur={handleBlur}
placeholder={getPlaceholder(filter)}
className="h-8 w-48"
/>
)}
</div>
);
})}
</>
)}
<button
onClick={onToggleMetricFilters}
className={cn(
'rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent',
hasCommonFilters && 'ml-auto'
)}
>
{showMetricFilters ? 'Hide' : 'Show'} Metric Filters
</button>
</div>
);
};

View File

@ -70,13 +70,16 @@ export const MetricChartCard = React.memo(
ref
) => {
const [metricSpecificFilterValues, setMetricSpecificFilterValues] = useState<Record<string, unknown>>({});
const { dashboardFilterValues } = useDashboardFilterValues();
const { dashboardFilterValues, showMetricFilters, isOnDashboard } = useDashboardFilterValues();
// Merge dashboard-level filters with metric-specific filters
const filterValues = useMemo(() => {
return { ...dashboardFilterValues, ...metricSpecificFilterValues };
}, [dashboardFilterValues, metricSpecificFilterValues]);
// Show metric filters if: standalone metric OR (on dashboard AND toggle is on)
const shouldShowMetricFilters = !isOnDashboard || showMetricFilters;
const { data: metric, isFetched: isFetchedMetric } = useGetMetric(
{ id: metricId, versionNumber },
{ select: stableMetricSelect, enabled: true }
@ -130,7 +133,9 @@ export const MetricChartCard = React.memo(
metricVersionNumber={versionNumber}
/>
<div className={'border-border border-b'} />
<MetricFilters filters={metric?.filters} onFilterValuesChange={setMetricSpecificFilterValues} />
{shouldShowMetricFilters && (
<MetricFilters filters={metric?.filters} onFilterValuesChange={setMetricSpecificFilterValues} />
)}
{renderChartContent && (
<MetricViewChartContent
chartConfig={memoizedChartConfig}

View File

@ -18,6 +18,8 @@ function canUseOperator(filter: MetricFilter): boolean {
return MODES_SUPPORTING_OPERATORS.has(filter.mode);
}
const CUSTOM_VALUE_KEY = '__custom__';
export const MetricFilters: React.FC<MetricFiltersProps> = ({
filters,
onFilterValuesChange,
@ -25,6 +27,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>>({});
if (!filters || filters.length === 0) {
return null;
@ -34,14 +37,31 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
setFilterValues({ ...filterValues, [key]: value });
};
const handleEnumChange = (key: string, value: string) => {
if (value === CUSTOM_VALUE_KEY) {
setCustomMode({ ...customMode, [key]: true });
setFilterValues({ ...filterValues, [key]: '' });
} else {
const newFilterValues = { ...filterValues, [key]: value };
setCustomMode({ ...customMode, [key]: false });
setFilterValues(newFilterValues);
// Immediately process the enum selection
processFilterValues(newFilterValues, filterOperators);
}
};
const handleOperatorChange = (key: string, op: string) => {
setFilterOperators({ ...filterOperators, [key]: op });
};
const handleBlur = () => {
const processFilterValues = (
currentFilterValues: Record<string, string>,
currentFilterOperators: Record<string, string>
) => {
// Convert values to appropriate types
const typedValues: Record<string, unknown> = {};
Object.entries(filterValues).forEach(([filterKey, filterValue]) => {
Object.entries(currentFilterValues).forEach(([filterKey, filterValue]) => {
const filter = filters.find((f) => f.key === filterKey);
if (!filter || !filterValue) return;
@ -63,7 +83,7 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
}
// Check if we should include operator override
const operator = filterOperators[filterKey];
const operator = currentFilterOperators[filterKey];
const shouldIncludeOperator = operator && canUseOperator(filter);
if (shouldIncludeOperator) {
@ -76,38 +96,65 @@ export const MetricFilters: React.FC<MetricFiltersProps> = ({
onFilterValuesChange(typedValues);
};
const handleBlur = () => {
processFilterValues(filterValues, filterOperators);
};
return (
<div className={cn('bg-muted/50 border-border flex flex-wrap gap-3 border-b p-3', className)}>
{filters.map((filter) => (
<div key={filter.key} className="flex items-center gap-2">
<label htmlFor={filter.key} className="text-muted-foreground text-sm font-medium">
{filter.key}:
</label>
{canUseOperator(filter) && (
<select
value={filterOperators[filter.key] || filter.op || '='}
onChange={(e) => handleOperatorChange(filter.key, e.target.value)}
onBlur={handleBlur}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{COMPARISON_OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
)}
<Input
id={filter.key}
type="text"
value={filterValues[filter.key] || ''}
onChange={(e) => handleInputChange(filter.key, e.target.value)}
onBlur={handleBlur}
placeholder={getPlaceholder(filter)}
className="h-8 w-48"
/>
</div>
))}
{filters.map((filter) => {
const hasEnum = filter.validate?.enum && filter.validate.enum.length > 0;
const isCustomMode = customMode[filter.key];
const showInput = !hasEnum || isCustomMode;
return (
<div key={filter.key} className="flex items-center gap-2">
<label htmlFor={filter.key} className="text-muted-foreground text-sm font-medium">
{filter.key}:
</label>
{canUseOperator(filter) && (
<select
value={filterOperators[filter.key] || filter.op || '='}
onChange={(e) => handleOperatorChange(filter.key, e.target.value)}
onBlur={handleBlur}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{COMPARISON_OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</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"
>
<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
id={filter.key}
type="text"
value={filterValues[filter.key] || ''}
onChange={(e) => handleInputChange(filter.key, e.target.value)}
onBlur={handleBlur}
placeholder={getPlaceholder(filter)}
className="h-8 w-48"
/>
)}
</div>
);
})}
</div>
);
};

View File

@ -34,7 +34,7 @@ const DashboardContentControllerInner: React.FC<{
onUpdateDashboardConfig,
}) => {
const [draggingId, setDraggingId] = useState<string | null>(null);
const { setDashboardFilterValues } = useDashboardFilterValues();
const { setDashboardFilterValues, showMetricFilters, setShowMetricFilters } = useDashboardFilterValues();
const commonFilters = useMemo(() => {
const filters = getCommonFilters(metrics);
@ -118,10 +118,12 @@ const DashboardContentControllerInner: React.FC<{
<div className="dashboard-content-controller overflow-visible">
{hasMetrics && !!dashboardRows.length && !!dashboard ? (
<DashboardContentControllerProvider dashboard={dashboard}>
{!readOnly && commonFilters.length > 0 && (
{!readOnly && (
<DashboardFilters
commonFilters={commonFilters}
onFilterValuesChange={setDashboardFilterValues}
showMetricFilters={showMetricFilters}
onToggleMetricFilters={() => setShowMetricFilters(!showMetricFilters)}
/>
)}
<BusterResizeableGrid

View File

@ -3,15 +3,25 @@ import React, { createContext, useContext, useState } from 'react';
interface DashboardFilterContextValue {
dashboardFilterValues: Record<string, unknown>;
setDashboardFilterValues: (values: Record<string, unknown>) => void;
showMetricFilters: boolean;
setShowMetricFilters: (show: boolean) => void;
}
const DashboardFilterContext = createContext<DashboardFilterContextValue | undefined>(undefined);
export const DashboardFilterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [dashboardFilterValues, setDashboardFilterValues] = useState<Record<string, unknown>>({});
const [showMetricFilters, setShowMetricFilters] = useState<boolean>(false);
return (
<DashboardFilterContext.Provider value={{ dashboardFilterValues, setDashboardFilterValues }}>
<DashboardFilterContext.Provider
value={{
dashboardFilterValues,
setDashboardFilterValues,
showMetricFilters,
setShowMetricFilters
}}
>
{children}
</DashboardFilterContext.Provider>
);
@ -20,7 +30,13 @@ export const DashboardFilterProvider: React.FC<{ children: React.ReactNode }> =
export const useDashboardFilterValues = () => {
const context = useContext(DashboardFilterContext);
if (!context) {
return { dashboardFilterValues: {}, setDashboardFilterValues: () => {} };
return {
dashboardFilterValues: {},
setDashboardFilterValues: () => {},
showMetricFilters: true, // Default to true when not on dashboard
setShowMetricFilters: () => {},
isOnDashboard: false
};
}
return context;
return { ...context, isOnDashboard: true };
};