fix: improve overview filters
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:'));
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user