feat: add exclude event filters

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 11:47:35 +01:00
parent 03c18b37ec
commit 7e2d93db45
6 changed files with 263 additions and 7 deletions

View File

@@ -35,6 +35,7 @@ interface PureFilterProps {
filter: IChartEventFilter,
) => void;
className?: string;
immediateInput?: boolean;
}
export function FilterItem({ filter, event }: FilterProps) {
@@ -113,6 +114,7 @@ export function PureFilterItem({
onChangeValue,
onChangeOperator,
className,
immediateInput,
}: PureFilterProps) {
const { projectId } = useAppParams();
@@ -170,6 +172,7 @@ export function PureFilterItem({
<InputEnter
value={filter.value[0] ? String(filter.value[0]) : ''}
onChangeValue={(value) => changeFilterValue([value])}
immediate={immediateInput}
/>
)}
</div>

View File

@@ -1,16 +1,26 @@
import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { PropertiesCombobox } from '@/components/report/sidebar/PropertiesCombobox';
import { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useEventNames } from '@/hooks/use-event-names';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
IProjectFilterEvent,
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { zProjectFilterEvent } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { SaveIcon } from 'lucide-react';
import { PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
@@ -22,9 +32,125 @@ type Props = {
const validator = z.object({
ips: z.array(z.string()),
profileIds: z.array(z.string()),
eventRules: z.array(zProjectFilterEvent.omit({ type: true })),
});
type IForm = z.infer<typeof validator>;
type IEventRule = IForm['eventRules'][number];
interface EventRuleItemProps {
projectId: string;
rule: IEventRule;
onChange: (rule: IEventRule) => void;
onRemove: () => void;
}
function EventRuleItem({
projectId,
rule,
onChange,
onRemove,
}: EventRuleItemProps) {
const eventNames = useEventNames({ projectId, anyEvents: false });
const addFilter = (action: {
value: string;
label: string;
description: string;
}) => {
onChange({
...rule,
filters: [
...rule.filters,
{ id: shortId(), name: action.value, operator: 'is', value: [] },
],
});
};
const removeFilter = (filter: IChartEventFilter) => {
onChange({
...rule,
filters: rule.filters.filter((f) => f.id !== filter.id),
});
};
const changeFilterValue = (
value: IChartEventFilterValue[],
filter: IChartEventFilter,
) => {
onChange({
...rule,
filters: rule.filters.map((f) =>
f.id === filter.id ? { ...f, value } : f,
),
});
};
const changeFilterOperator = (
operator: IChartEventFilterOperator,
filter: IChartEventFilter,
) => {
onChange({
...rule,
filters: rule.filters.map((f) =>
f.id === filter.id
? { ...f, operator, value: f.value.filter(Boolean).slice(0, 1) }
: f,
),
});
};
return (
<div className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-4">
<div className="flex-1">
<ComboboxEvents
placeholder="Select event name..."
items={eventNames}
value={rule.name}
onChange={(name) => onChange({ ...rule, name })}
className="w-full"
searchable
/>
</div>
<Button variant="ghost" size="icon" onClick={onRemove}>
<Trash2Icon size={16} />
</Button>
</div>
{rule.filters.length > 0 && (
<>
{rule.filters.map((filter) => (
<PureFilterItem
key={filter.id}
filter={filter}
eventName={rule.name}
onRemove={removeFilter}
onChangeValue={changeFilterValue}
onChangeOperator={changeFilterOperator}
immediateInput
className="border-t p-2 px-4 border-l-2 border-l-emerald-500"
/>
))}
</>
)}
<div className="p-4 border-t">
<PropertiesCombobox onSelect={addFilter} mode="events">
{(setOpen) => (
<Button
variant="outline"
size="sm"
onClick={() => setOpen(true)}
icon={PlusIcon}
>
Add property filter
</Button>
)}
</PropertiesCombobox>
</div>
</div>
);
}
export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({
@@ -38,6 +164,15 @@ export default function EditProjectFilters({ project }: Props) {
(item): item is IProjectFilterProfileId => item.type === 'profile_id',
)
.map((item) => item.profileId),
eventRules: project.filters
.filter((item): item is IProjectFilterEvent => item.type === 'event')
.map(({ name, filters, segment, property, displayName }) => ({
name,
filters,
segment,
property,
displayName,
})),
},
});
@@ -60,10 +195,38 @@ export default function EditProjectFilters({ project }: Props) {
type: 'profile_id' as const,
profileId,
})),
...values.eventRules
.filter((rule) => rule.name)
.map((rule) => ({
type: 'event' as const,
...rule,
})),
],
});
};
const eventRules = form.watch('eventRules');
const addEventRule = () => {
form.setValue('eventRules', [
...eventRules,
{ name: '', filters: [], segment: 'event' },
]);
};
const updateEventRule = (index: number, rule: IEventRule) => {
const updated = [...eventRules];
updated[index] = rule;
form.setValue('eventRules', updated);
};
const removeEventRule = (index: number) => {
form.setValue(
'eventRules',
eventRules.filter((_, i) => i !== index),
);
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="space-y-2">
@@ -73,7 +236,15 @@ export default function EditProjectFilters({ project }: Props) {
</p>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form
onSubmit={form.handleSubmit(onSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) {
e.preventDefault();
}
}}
className="space-y-4"
>
<Controller
name="ips"
control={form.control}
@@ -108,6 +279,30 @@ export default function EditProjectFilters({ project }: Props) {
)}
/>
<WithLabel label="Event rules">
<div className="space-y-3">
{eventRules.map((rule, index) => (
<EventRuleItem
// biome-ignore lint/suspicious/noArrayIndexKey: order is stable
key={index}
projectId={project.id}
rule={rule}
onChange={(updated) => updateEventRule(index, updated)}
onRemove={() => removeEventRule(index)}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEventRule}
icon={PlusIcon}
>
Add event rule
</Button>
</div>
</WithLabel>
<Button
loading={mutation.isPending}
type="submit"

View File

@@ -9,10 +9,12 @@ import { Input, type InputProps } from './input';
export function InputEnter({
value,
onChangeValue,
immediate,
...props
}: {
value: string | undefined;
onChangeValue: (value: string) => void;
immediate?: boolean;
} & InputProps) {
const [internalValue, setInternalValue] = useState(value ?? '');
@@ -27,7 +29,12 @@ export function InputEnter({
<Input
{...props}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onChange={(e) => {
setInternalValue(e.target.value);
if (immediate) {
onChangeValue(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
@@ -36,7 +43,7 @@ export function InputEnter({
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
{!immediate && internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}