fix: improve overview filters

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 10:43:55 +01:00
parent 1281cfa7b3
commit d7e6e737c9
9 changed files with 215 additions and 100 deletions

View File

@@ -1,10 +1,8 @@
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { GlobeIcon } from 'lucide-react';
export function OriginFilter() {
const { projectId } = useAppParams();
@@ -14,12 +12,8 @@ export function OriginFilter() {
const { data } = useQuery(
trpc.event.origin.queryOptions(
{
projectId: projectId,
},
{
staleTime: 1000 * 60 * 60,
},
{ projectId },
{ staleTime: 1000 * 60 * 60 },
),
);
@@ -28,20 +22,23 @@ export function OriginFilter() {
}
return (
<div className="flex flex-wrap gap-2">
{data?.map((item) => {
<div className="flex flex-wrap gap-1.5">
{data.map((item) => {
const active = originFilter?.value.includes(item.origin);
return (
<Button
<button
key={item.origin}
variant="outline"
icon={GlobeIcon}
className={cn(
originFilter?.value.includes(item.origin) && 'border-foreground',
)}
type="button"
onClick={() => setFilter('origin', [item.origin], 'is')}
className={cn(
'rounded-md border px-2.5 py-1 text-sm transition-colors cursor-pointer truncate max-w-56',
active
? 'bg-foreground text-background border-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:border-foreground/30',
)}
>
{item.origin}
</Button>
</button>
);
})}
</div>

View File

@@ -1,13 +1,21 @@
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
import { useAppParams } from '@/hooks/use-app-params';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { usePropertyValues } from '@/hooks/use-property-values';
import { pushModal } from '@/modals';
import type { OverviewFiltersProps } from '@/modals/overview-filters';
import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn';
import { operators } from '@openpanel/constants';
import type {
IChartEventFilter,
IChartEventFilterOperator,
} from '@openpanel/validation';
import { FilterIcon, X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -33,14 +41,95 @@ export function OverviewFilterButton(props: OverviewFiltersProps) {
);
}
interface FilterPillProps {
filter: IChartEventFilter;
nuqsOptions?: NuqsOptions;
onRemove: () => void;
onChangeOperator: (operator: IChartEventFilterOperator) => void;
onChangeValue: (value: string[]) => void;
}
function FilterPill({
filter,
nuqsOptions,
onRemove,
onChangeOperator,
onChangeValue,
}: FilterPillProps) {
const { projectId } = useAppParams();
const potentialValues = usePropertyValues({
event: '*',
property: filter.name,
projectId,
});
const noValueNeeded =
filter.operator === 'isNull' || filter.operator === 'isNotNull';
return (
<div className="flex items-stretch text-sm border rounded-md overflow-hidden h-8">
{/* Key — opens modal to change the property */}
<button
type="button"
onClick={() => pushModal('OverviewFilters', { nuqsOptions })}
className="px-2 hover:bg-accent transition-colors cursor-pointer"
>
{getPropertyLabel(filter.name)}
</button>
{/* Operator dropdown */}
<FilterOperatorSelect value={filter.operator} onChange={onChangeOperator}>
<button
type="button"
className="px-2 opacity-50 lowercase hover:opacity-100 hover:bg-accent transition-colors border-l cursor-pointer"
>
{operators[filter.operator]}
</button>
</FilterOperatorSelect>
{/* Value picker — only when operator needs a value */}
{!noValueNeeded && (
<ComboboxAdvanced
items={potentialValues.map((v) => ({ value: v, label: v }))}
value={filter.value}
onChange={onChangeValue}
>
<button
type="button"
className="px-2 font-semibold hover:bg-accent transition-colors border-l cursor-pointer max-w-40 truncate"
>
{filter.value.length > 0 ? (
filter.value.join(', ')
) : (
<span className="opacity-40 font-normal italic">pick value</span>
)}
</button>
</ComboboxAdvanced>
)}
{/* Remove */}
<button
type="button"
onClick={onRemove}
className="px-2 hover:bg-destructive hover:text-destructive-foreground transition-colors border-l cursor-pointer"
aria-label="Remove filter"
>
<X className="size-3" />
</button>
</div>
);
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, _setFilter, _setFilters, removeFilter] =
const [filters, setFilter, _setFilters, removeFilter] =
useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{events.map((event) => (
@@ -54,27 +143,20 @@ export function OverviewFiltersButtons({
<strong className="font-semibold">{event}</strong>
</Button>
))}
{filters.map((filter) => {
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => removeFilter(filter.name)}
>
<span>{getPropertyLabel(filter.name)}</span>
<span className="opacity-40 ml-2 lowercase">
{operators[filter.operator]}
</span>
{filter.value.length > 0 && (
<strong className="font-semibold ml-2">
{filter.value.join(', ')}
</strong>
)}
</Button>
);
})}
{filters.map((filter) => (
<FilterPill
key={filter.name}
filter={filter}
nuqsOptions={nuqsOptions}
onRemove={() => removeFilter(filter.name)}
onChangeOperator={(operator) =>
setFilter(filter.name, filter.value, operator)
}
onChangeValue={(value) =>
setFilter(filter.name, value, filter.operator)
}
/>
))}
</div>
);
}

View File

@@ -1,20 +1,19 @@
import { ColorSquare } from '@/components/color-square';
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
import { RenderDots } from '@/components/ui/RenderDots';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params';
import { usePropertyValues } from '@/hooks/use-property-values';
import { useDispatch } from '@/redux';
import { operators } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { changeEvent } from '../../reportSlice';
@@ -155,18 +154,10 @@ export function PureFilterItem({
</Button>
</div>
<div className="flex gap-1">
<DropdownMenuComposed
<FilterOperatorSelect
value={filter.operator}
onChange={changeFilterOperator}
items={mapKeys(operators).map((key) => ({
value: key,
label: operators[key],
}))}
label="Operator"
>
<Button variant={'outline'} className="whitespace-nowrap">
{operators[filter.operator]}
</Button>
</DropdownMenuComposed>
/>
{filter.operator === 'is' || filter.operator === 'isNot' ? (
<ComboboxAdvanced
items={valuesCombobox}

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { operators } from '@openpanel/constants';
import type { IChartEventFilterOperator } from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
interface FilterOperatorSelectProps {
value: IChartEventFilterOperator;
onChange: (operator: IChartEventFilterOperator) => void;
children?: React.ReactNode;
}
export function FilterOperatorSelect({
value,
onChange,
children,
}: FilterOperatorSelectProps) {
const trigger = children ?? (
<Button variant="outline" className="whitespace-nowrap">
{operators[value]}
</Button>
);
return (
<DropdownMenuComposed
onChange={onChange}
items={mapKeys(operators).map((key) => ({
value: key,
label: operators[key],
}))}
label="Operator"
>
{trigger}
</DropdownMenuComposed>
);
}

View File

@@ -1,13 +1,11 @@
import { Badge } from '@/components/ui/badge';
import { Command, CommandInput, CommandItem } from '@/components/ui/command';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import * as React from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { Button, type ButtonProps } from './button';
import { Checkbox, DumpCheckbox } from './checkbox';
import { DumpCheckbox } from './checkbox';
import {
Popover,
PopoverContent,
@@ -30,9 +28,10 @@ interface ComboboxAdvancedProps {
value: IValue[];
onChange: (value: IValue[]) => void;
items: IItem[];
placeholder: string;
placeholder?: string;
className?: string;
size?: ButtonProps['size'];
children?: React.ReactNode;
}
export function ComboboxAdvanced({
@@ -42,11 +41,10 @@ export function ComboboxAdvanced({
placeholder,
className,
size,
children,
}: ComboboxAdvancedProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const ref = React.useRef<HTMLDivElement>(null);
useOnClickOutside(ref as React.RefObject<HTMLElement>, () => setOpen(false));
const selectables = items
.filter((item) => !value.find((s) => s === item.value))
@@ -96,42 +94,32 @@ export function ComboboxAdvanced({
...value.map((val) => {
const item = items.find((item) => item.value === val);
return item
? {
value: val,
label: item.label,
}
: {
value: val,
label: val,
};
? { value: val, label: item.label }
: { value: val, label: val };
}),
...selectables,
].filter((item) => item.value);
}, [inputValue, selectables, items]);
const trigger = children ?? (
<Button variant={'outline'} className={className} size={size} autoHeight>
<div className="flex w-full flex-wrap gap-1">
{value.length === 0 && placeholder}
{value.map((val) => {
const item = items.find((item) => item.value === val) ?? {
value: val,
label: val,
};
return <Badge key={String(item.value)}>{item.label}</Badge>;
})}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
className={className}
size={size}
autoHeight
>
<div className="flex w-full flex-wrap gap-1">
{value.length === 0 && placeholder}
{value.map((value) => {
const item = items.find((item) => item.value === value) ?? {
value,
label: value,
};
return <Badge key={String(item.value)}>{item.label}</Badge>;
})}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-full max-w-md p-0" align="start">
<Command shouldFilter={false}>

View File

@@ -62,8 +62,8 @@ function DialogContent({
'max-h-screen overflow-y-auto overflow-x-hidden', // Ensure the dialog is scrollable if it exceeds the screen height
'mt-auto', // Add margin-top: auto for all screen sizes
'focus:outline-none focus:ring-0 transition-none',
'border bg-background shadow-lg sm:rounded-lg md:max-h-[90vh]',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'border bg-def-100 shadow-lg sm:rounded-lg md:max-h-[90vh]',
'bg-def-100 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}

View File

@@ -29,16 +29,16 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 flex flex-col gap-4 overflow-y-auto rounded-lg bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out max-sm:w-[calc(100%-theme(spacing.8))]',
'fixed z-50 flex flex-col gap-4 overflow-y-auto rounded-lg bg-def-100 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out max-sm:w-[calc(100%-theme(spacing.8))]',
{
variants: {
side: {
top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
top: 'inset-x-4 top-4 border data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'bottom-4 left-4 top-4 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
'inset-x-4 bottom-4 border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'bottom-4 left-4 top-4 w-3/4 border data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'bottom-4 right-4 top-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
'bottom-4 right-4 top-4 w-3/4 border data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {

View File

@@ -8,7 +8,7 @@ import {
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useProfileValues } from '@/hooks/use-profile-values';
import { FilterIcon, XIcon } from 'lucide-react';
import { FilterIcon, GanttChartIcon, GlobeIcon, LucideIcon, SlidersHorizontal, XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type {
@@ -30,6 +30,16 @@ export interface OverviewFiltersProps {
mode?: 'events' | 'profile';
}
const Seperator = () => <div className="h-px bg-border -mx-6" />
const Heading = ({ title, icon: Icon }: { title: string, icon: LucideIcon }) => (
<div className="row items-center gap-2">
<Icon className="size-4" />
<h2 className="text-sm font-medium">
{title}
</h2>
</div>
);
export default function OverviewFilters({
nuqsOptions,
enableEventsFilter,
@@ -44,8 +54,12 @@ export default function OverviewFilters({
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title="Filters" />
<div className="flex flex-col gap-4">
<Heading icon={GlobeIcon} title="Origins" />
<OriginFilter />
<Seperator />
{enableEventsFilter && (
<>
<Heading icon={GanttChartIcon} title="Events" />
<ComboboxEvents
size="lg"
className="w-full"
@@ -56,16 +70,23 @@ export default function OverviewFilters({
placeholder="Select event"
maxDisplayItems={2}
searchable
/>
/>
<Seperator />
</>
)}
</div>
<Heading icon={SlidersHorizontal} title="Filters" />
<div className="flex flex-col gap-2">
<div
className={cn(
'bg-def-200 rounded-lg border',
selectedFilters.length === 0 && 'hidden',
'bg-card rounded-lg border',
)}
>
{selectedFilters.length === 0 && (
<div className="p-4 text-center text-sm text-muted-foreground">
No filters selected
</div>
)}
{selectedFilters.map((filter) => {
return (
<PureFilterItem

View File

@@ -351,6 +351,6 @@ export const eventRouter = createTRPCRouter({
)} AND origin IS NOT NULL AND origin != '' AND toDate(created_at) > now() - INTERVAL 30 DAY GROUP BY origin ORDER BY count DESC LIMIT 3`,
);
return res;
return res.filter((item) => item.origin && !item.origin.includes('localhost:'));
}),
});