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 { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { GlobeIcon } from 'lucide-react';
export function OriginFilter() { export function OriginFilter() {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
@@ -14,12 +12,8 @@ export function OriginFilter() {
const { data } = useQuery( const { data } = useQuery(
trpc.event.origin.queryOptions( trpc.event.origin.queryOptions(
{ { projectId },
projectId: projectId, { staleTime: 1000 * 60 * 60 },
},
{
staleTime: 1000 * 60 * 60,
},
), ),
); );
@@ -28,20 +22,23 @@ export function OriginFilter() {
} }
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{data?.map((item) => { {data.map((item) => {
const active = originFilter?.value.includes(item.origin);
return ( return (
<Button <button
key={item.origin} key={item.origin}
variant="outline" type="button"
icon={GlobeIcon}
className={cn(
originFilter?.value.includes(item.origin) && 'border-foreground',
)}
onClick={() => setFilter('origin', [item.origin], 'is')} 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} {item.origin}
</Button> </button>
); );
})} })}
</div> </div>

View File

@@ -1,13 +1,21 @@
import { Button } from '@/components/ui/button'; 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 { import {
useEventQueryFilters, useEventQueryFilters,
useEventQueryNamesFilter, useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters'; } from '@/hooks/use-event-query-filters';
import { usePropertyValues } from '@/hooks/use-property-values';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { OverviewFiltersProps } from '@/modals/overview-filters'; import type { OverviewFiltersProps } from '@/modals/overview-filters';
import { getPropertyLabel } from '@/translations/properties'; import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { operators } from '@openpanel/constants'; import { operators } from '@openpanel/constants';
import type {
IChartEventFilter,
IChartEventFilterOperator,
} from '@openpanel/validation';
import { FilterIcon, X } from 'lucide-react'; import { FilterIcon, X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs'; 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({ export function OverviewFiltersButtons({
className, className,
nuqsOptions, nuqsOptions,
}: OverviewFiltersButtonsProps) { }: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions); const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, _setFilter, _setFilters, removeFilter] = const [filters, setFilter, _setFilters, removeFilter] =
useEventQueryFilters(nuqsOptions); useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null; if (filters.length === 0 && events.length === 0) return null;
return ( return (
<div className={cn('flex flex-wrap gap-2', className)}> <div className={cn('flex flex-wrap gap-2', className)}>
{events.map((event) => ( {events.map((event) => (
@@ -54,27 +143,20 @@ export function OverviewFiltersButtons({
<strong className="font-semibold">{event}</strong> <strong className="font-semibold">{event}</strong>
</Button> </Button>
))} ))}
{filters.map((filter) => { {filters.map((filter) => (
return ( <FilterPill
<Button key={filter.name}
key={filter.name} filter={filter}
size="sm" nuqsOptions={nuqsOptions}
variant="outline" onRemove={() => removeFilter(filter.name)}
icon={X} onChangeOperator={(operator) =>
onClick={() => removeFilter(filter.name)} setFilter(filter.name, filter.value, operator)
> }
<span>{getPropertyLabel(filter.name)}</span> onChangeValue={(value) =>
<span className="opacity-40 ml-2 lowercase"> setFilter(filter.name, value, filter.operator)
{operators[filter.operator]} }
</span> />
{filter.value.length > 0 && ( ))}
<strong className="font-semibold ml-2">
{filter.value.join(', ')}
</strong>
)}
</Button>
);
})}
</div> </div>
); );
} }

View File

@@ -1,20 +1,19 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
import { RenderDots } from '@/components/ui/RenderDots'; import { RenderDots } from '@/components/ui/RenderDots';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { InputEnter } from '@/components/ui/input-enter'; import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { usePropertyValues } from '@/hooks/use-property-values'; import { usePropertyValues } from '@/hooks/use-property-values';
import { useDispatch } from '@/redux'; import { useDispatch } from '@/redux';
import { operators } from '@openpanel/constants';
import type { import type {
IChartEvent, IChartEvent,
IChartEventFilter, IChartEventFilter,
IChartEventFilterOperator, IChartEventFilterOperator,
IChartEventFilterValue, IChartEventFilterValue,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
import { SlidersHorizontal, Trash } from 'lucide-react'; import { SlidersHorizontal, Trash } from 'lucide-react';
import { changeEvent } from '../../reportSlice'; import { changeEvent } from '../../reportSlice';
@@ -155,18 +154,10 @@ export function PureFilterItem({
</Button> </Button>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<DropdownMenuComposed <FilterOperatorSelect
value={filter.operator}
onChange={changeFilterOperator} 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' ? ( {filter.operator === 'is' || filter.operator === 'isNot' ? (
<ComboboxAdvanced <ComboboxAdvanced
items={valuesCombobox} 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 { Badge } from '@/components/ui/badge';
import { Command, CommandInput, CommandItem } from '@/components/ui/command'; import { Command, CommandInput, CommandItem } from '@/components/ui/command';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react'; import { ChevronsUpDownIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list'; import VirtualList from 'rc-virtual-list';
import * as React from 'react'; import * as React from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { Button, type ButtonProps } from './button'; import { Button, type ButtonProps } from './button';
import { Checkbox, DumpCheckbox } from './checkbox'; import { DumpCheckbox } from './checkbox';
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -30,9 +28,10 @@ interface ComboboxAdvancedProps {
value: IValue[]; value: IValue[];
onChange: (value: IValue[]) => void; onChange: (value: IValue[]) => void;
items: IItem[]; items: IItem[];
placeholder: string; placeholder?: string;
className?: string; className?: string;
size?: ButtonProps['size']; size?: ButtonProps['size'];
children?: React.ReactNode;
} }
export function ComboboxAdvanced({ export function ComboboxAdvanced({
@@ -42,11 +41,10 @@ export function ComboboxAdvanced({
placeholder, placeholder,
className, className,
size, size,
children,
}: ComboboxAdvancedProps) { }: ComboboxAdvancedProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState(''); const [inputValue, setInputValue] = React.useState('');
const ref = React.useRef<HTMLDivElement>(null);
useOnClickOutside(ref as React.RefObject<HTMLElement>, () => setOpen(false));
const selectables = items const selectables = items
.filter((item) => !value.find((s) => s === item.value)) .filter((item) => !value.find((s) => s === item.value))
@@ -96,42 +94,32 @@ export function ComboboxAdvanced({
...value.map((val) => { ...value.map((val) => {
const item = items.find((item) => item.value === val); const item = items.find((item) => item.value === val);
return item return item
? { ? { value: val, label: item.label }
value: val, : { value: val, label: val };
label: item.label,
}
: {
value: val,
label: val,
};
}), }),
...selectables, ...selectables,
].filter((item) => item.value); ].filter((item) => item.value);
}, [inputValue, selectables, items]); }, [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 ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>{trigger}</PopoverTrigger>
<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>
<PopoverPortal> <PopoverPortal>
<PopoverContent className="w-full max-w-md p-0" align="start"> <PopoverContent className="w-full max-w-md p-0" align="start">
<Command shouldFilter={false}> <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 '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 'mt-auto', // Add margin-top: auto for all screen sizes
'focus:outline-none focus:ring-0 transition-none', 'focus:outline-none focus:ring-0 transition-none',
'border bg-background shadow-lg sm:rounded-lg md:max-h-[90vh]', 'border bg-def-100 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', '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, className,
)} )}
{...props} {...props}

View File

@@ -29,16 +29,16 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( 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: { variants: {
side: { 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: bottom:
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', '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-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', 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: 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: { defaultVariants: {

View File

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