feature(dashboard): filter on profile properties and support drag n drop for events
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/trpc/client';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Debug() {
|
||||
const [sameSite, setSameSite] = useState<'lax' | 'strict' | 'none'>('lax');
|
||||
const [domain, setDomain] = useState<string>('localhost');
|
||||
const cookiePost = api.user.debugPostCookie.useMutation();
|
||||
const cookieGet = api.user.debugGetCookie.useQuery({
|
||||
domain,
|
||||
sameSite,
|
||||
});
|
||||
return (
|
||||
<div className="col gap-8">
|
||||
<input
|
||||
className="border p-4"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="border p-4"
|
||||
value={sameSite}
|
||||
onChange={(e) =>
|
||||
setSameSite(e.target.value as 'lax' | 'strict' | 'none')
|
||||
}
|
||||
>
|
||||
<option value="lax">Lax</option>
|
||||
<option value="strict">Strict</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
<Button onClick={() => cookiePost.mutate({ domain, sameSite })}>
|
||||
Set Cookie (POST)
|
||||
</Button>
|
||||
<Button onClick={() => cookieGet.refetch()}>Set Cookie (GET)</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Debug } from './Debug';
|
||||
|
||||
export default function Page() {
|
||||
return <Debug />;
|
||||
}
|
||||
@@ -278,6 +278,17 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.funnelWindow = action.payload || undefined;
|
||||
},
|
||||
reorderEvents(
|
||||
state,
|
||||
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
|
||||
) {
|
||||
state.dirty = true;
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const [movedEvent] = state.events.splice(fromIndex, 1);
|
||||
if (movedEvent) {
|
||||
state.events.splice(toIndex, 0, movedEvent);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -307,6 +318,7 @@ export const {
|
||||
changeUnit,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
reorderEvents,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
@@ -2,25 +2,178 @@
|
||||
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useEventNames } from '@/hooks/useEventNames';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||
import { GanttChartIcon, HandIcon, Users } from 'lucide-react';
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
function SortableEvent({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: event.id ?? '' });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
{(showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<DropdownMenuComposed
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'event',
|
||||
label: 'All events',
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
label: 'Unique users',
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Unique sessions',
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
</>
|
||||
) : event.segment === 'session' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique sessions
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChartIcon size={12} /> All events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuComposed>
|
||||
)}
|
||||
{showAddFilter && <FiltersCombobox event={event} />}
|
||||
|
||||
{showSegment &&
|
||||
(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportEvents() {
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
@@ -40,6 +193,24 @@ export function ReportEvents() {
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedEvents.findIndex((e) => e.id === active.id);
|
||||
const newIndex = selectedEvents.findIndex((e) => e.id === over.id);
|
||||
|
||||
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
@@ -55,214 +226,135 @@ export function ReportEvents() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Events</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="rounded-lg border bg-def-100">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
||||
{isSelectManyEvents ? (
|
||||
<ComboboxAdvanced
|
||||
className="flex-1"
|
||||
value={event.filters[0]?.value ?? []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
id: event.id,
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
) : (
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
)}
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
{(showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0 ">
|
||||
{showSegment && (
|
||||
<DropdownMenuComposed
|
||||
onChange={(segment) => {
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
return (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
{isSelectManyEvents ? (
|
||||
<ComboboxAdvanced
|
||||
className="flex-1"
|
||||
value={event.filters[0]?.value ?? []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
id: event.id,
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'event',
|
||||
label: 'All events',
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
label: 'Unique users',
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Unique sessions',
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
</>
|
||||
) : event.segment === 'session' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique sessions
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuComposed>
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
) : (
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
)}
|
||||
{/* */}
|
||||
{showAddFilter && <FiltersCombobox event={event} />}
|
||||
|
||||
{showSegment &&
|
||||
(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Combobox
|
||||
disabled={isAddEventDisabled}
|
||||
icon={GanttChartIcon}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</SortableEvent>
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
})}
|
||||
|
||||
<Combobox
|
||||
disabled={isAddEventDisabled}
|
||||
icon={GanttChartIcon}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ 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/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
import { operators } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
@@ -17,8 +16,7 @@ import type {
|
||||
IChartEventFilterValue,
|
||||
} from '@openpanel/validation';
|
||||
import { mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
@@ -105,7 +103,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
onRemove={onRemove}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeOperator={onChangeOperator}
|
||||
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-def-200 first:border-t"
|
||||
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-def-300 first:border-t"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,58 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useEventProperties } from '@/hooks/useEventProperties';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
|
||||
import { useDispatch } from '@/redux';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FiltersComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
function SearchHeader({
|
||||
onBack,
|
||||
onSearch,
|
||||
value,
|
||||
}: {
|
||||
onBack?: () => void;
|
||||
onSearch: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="row items-center gap-1">
|
||||
{!!onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={value}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const properties = useEventProperties(
|
||||
{
|
||||
event: event.name,
|
||||
@@ -26,39 +62,219 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
enabled: !!event.name,
|
||||
},
|
||||
);
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
||||
const [search, setSearch] = useState('');
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState('index');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Mock data for the lists
|
||||
const profileActions = properties
|
||||
.filter((property) => property.startsWith('profile'))
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
label: property.split('.').pop() ?? property,
|
||||
description: property.split('.').slice(0, -1).join('.'),
|
||||
}));
|
||||
const eventActions = properties
|
||||
.filter((property) => !property.startsWith('profile'))
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
label: property.split('.').pop() ?? property,
|
||||
description: property.split('.').slice(0, -1).join('.'),
|
||||
}));
|
||||
|
||||
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
|
||||
setDirection(newState === 'index' ? 'backward' : 'forward');
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
const handleSelect = (action: {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}) => {
|
||||
setOpen(false);
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const renderIndex = () => {
|
||||
return (
|
||||
<DropdownMenuGroup>
|
||||
<SearchHeader onSearch={() => {}} value={search} />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="group justify-between"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleStateChange('event');
|
||||
}}
|
||||
>
|
||||
Event properties
|
||||
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group justify-between"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleStateChange('profile');
|
||||
}}
|
||||
>
|
||||
Profile properties
|
||||
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEvent = () => {
|
||||
const filteredActions = eventActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col">
|
||||
<SearchHeader
|
||||
onBack={() => handleStateChange('index')}
|
||||
onSearch={setSearch}
|
||||
value={search}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</VirtualList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProfile = () => {
|
||||
const filteredActions = profileActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SearchHeader
|
||||
onBack={() => handleStateChange('index')}
|
||||
onSearch={setSearch}
|
||||
value={search}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</VirtualList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties.map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
</Combobox>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-80" align="start">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{state === 'index' && (
|
||||
<motion.div
|
||||
key="index"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderIndex()}
|
||||
</motion.div>
|
||||
)}
|
||||
{state === 'event' && (
|
||||
<motion.div
|
||||
key="event"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderEvent()}
|
||||
</motion.div>
|
||||
)}
|
||||
{state === 'profile' && (
|
||||
<motion.div
|
||||
key="profile"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderProfile()}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ReportEventFiltersProps {
|
||||
export function FiltersList({ event }: ReportEventFiltersProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-def-100 flex flex-col divide-y">
|
||||
<div className="bg-def-100 flex flex-col divide-y overflow-hidden rounded-b-md">
|
||||
{event.filters.map((filter) => {
|
||||
return <FilterItem key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user