feature(dashboard): filter on profile properties and support drag n drop for events
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^1.2.5",
|
"@ai-sdk/react": "^1.2.5",
|
||||||
"@clickhouse/client": "^1.2.0",
|
"@clickhouse/client": "^1.2.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||||
"@openpanel/auth": "workspace:^",
|
"@openpanel/auth": "workspace:^",
|
||||||
|
|||||||
@@ -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.dirty = true;
|
||||||
state.funnelWindow = action.payload || undefined;
|
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,
|
changeUnit,
|
||||||
changeFunnelGroup,
|
changeFunnelGroup,
|
||||||
changeFunnelWindow,
|
changeFunnelWindow,
|
||||||
|
reorderEvents,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
export default reportSlice.reducer;
|
export default reportSlice.reducer;
|
||||||
|
|||||||
@@ -2,130 +2,79 @@
|
|||||||
|
|
||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||||
import { useEventNames } from '@/hooks/useEventNames';
|
import { useEventNames } from '@/hooks/useEventNames';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
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 { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import type { IChartEvent } from '@openpanel/validation';
|
||||||
|
import { GanttChartIcon, HandIcon, Users } from 'lucide-react';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import {
|
||||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
addEvent,
|
||||||
|
changeEvent,
|
||||||
|
removeEvent,
|
||||||
|
reorderEvents,
|
||||||
|
} from '../reportSlice';
|
||||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||||
import { ReportEventMore } from './ReportEventMore';
|
|
||||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||||
|
import { ReportEventMore } from './ReportEventMore';
|
||||||
import { FiltersCombobox } from './filters/FiltersCombobox';
|
import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||||
import { FiltersList } from './filters/FiltersList';
|
import { FiltersList } from './filters/FiltersList';
|
||||||
|
|
||||||
export function ReportEvents() {
|
function SortableEvent({
|
||||||
const selectedEvents = useSelector((state) => state.report.events);
|
event,
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
index,
|
||||||
|
showSegment,
|
||||||
|
showAddFilter,
|
||||||
|
isSelectManyEvents,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
event: IChartEvent;
|
||||||
|
index: number;
|
||||||
|
showSegment: boolean;
|
||||||
|
showAddFilter: boolean;
|
||||||
|
isSelectManyEvents: boolean;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
const eventNames = useEventNames({
|
useSortable({ id: event.id ?? '' });
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
|
||||||
const showAddFilter = !['retention'].includes(chartType);
|
|
||||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
|
||||||
const isAddEventDisabled =
|
|
||||||
(chartType === 'retention' || chartType === 'conversion') &&
|
|
||||||
selectedEvents.length >= 2;
|
|
||||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
|
||||||
dispatch(changeEvent(event));
|
|
||||||
});
|
|
||||||
const isSelectManyEvents = chartType === 'retention';
|
|
||||||
|
|
||||||
const handleMore = (event: IChartEvent) => {
|
const style = {
|
||||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
transform: CSS.Transform.toString(transform),
|
||||||
switch (action) {
|
transition,
|
||||||
case 'remove': {
|
|
||||||
return dispatch(removeEvent(event));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return callback;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||||
<h3 className="mb-2 font-medium">Events</h3>
|
<div className="flex items-center gap-2 p-2 group">
|
||||||
<div className="flex flex-col gap-4">
|
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||||
{selectedEvents.map((event, index) => {
|
<ColorSquare className="relative">
|
||||||
return (
|
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||||
<div key={event.id} className="rounded-lg border bg-def-100">
|
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||||
<div className="flex items-center gap-2 p-2">
|
{alphabetIds[index]}
|
||||||
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
</span>
|
||||||
{isSelectManyEvents ? (
|
</ColorSquare>
|
||||||
<ComboboxAdvanced
|
</button>
|
||||||
className="flex-1"
|
{props.children}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Segment and Filter buttons */}
|
{/* Segment and Filter buttons */}
|
||||||
@@ -203,13 +152,12 @@ export function ReportEvents() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GanttChart size={12} /> All events
|
<GanttChartIcon size={12} /> All events
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuComposed>
|
</DropdownMenuComposed>
|
||||||
)}
|
)}
|
||||||
{/* */}
|
|
||||||
{showAddFilter && <FiltersCombobox event={event} />}
|
{showAddFilter && <FiltersCombobox event={event} />}
|
||||||
|
|
||||||
{showSegment &&
|
{showSegment &&
|
||||||
@@ -224,6 +172,148 @@ export function ReportEvents() {
|
|||||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportEvents() {
|
||||||
|
const selectedEvents = useSelector((state) => state.report.events);
|
||||||
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const eventNames = useEventNames({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||||
|
const showAddFilter = !['retention'].includes(chartType);
|
||||||
|
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||||
|
const isAddEventDisabled =
|
||||||
|
(chartType === 'retention' || chartType === 'conversion') &&
|
||||||
|
selectedEvents.length >= 2;
|
||||||
|
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||||
|
dispatch(changeEvent(event));
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
case 'remove': {
|
||||||
|
return dispatch(removeEvent(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 font-medium">Events</h3>
|
||||||
|
<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({
|
||||||
|
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)} />
|
||||||
|
</SortableEvent>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -263,6 +353,8 @@ export function ReportEvents() {
|
|||||||
placeholder="Select event"
|
placeholder="Select event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ 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 { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useMappings } from '@/hooks/useMappings';
|
import { useMappings } from '@/hooks/useMappings';
|
||||||
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
|
||||||
|
|
||||||
import { operators } from '@openpanel/constants';
|
import { operators } from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
@@ -17,8 +16,7 @@ import type {
|
|||||||
IChartEventFilterValue,
|
IChartEventFilterValue,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { mapKeys } from '@openpanel/validation';
|
import { mapKeys } from '@openpanel/validation';
|
||||||
|
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||||
import { InputEnter } from '@/components/ui/input-enter';
|
|
||||||
import { changeEvent } from '../../reportSlice';
|
import { changeEvent } from '../../reportSlice';
|
||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
@@ -105,7 +103,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onChangeValue={onChangeValue}
|
onChangeValue={onChangeValue}
|
||||||
onChangeOperator={onChangeOperator}
|
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 { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useEventProperties } from '@/hooks/useEventProperties';
|
import { useEventProperties } from '@/hooks/useEventProperties';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch } from '@/redux';
|
||||||
import { FilterIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
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';
|
import { changeEvent } from '../../reportSlice';
|
||||||
|
|
||||||
interface FiltersComboboxProps {
|
interface FiltersComboboxProps {
|
||||||
event: IChartEvent;
|
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) {
|
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const properties = useEventProperties(
|
const properties = useEventProperties(
|
||||||
{
|
{
|
||||||
event: event.name,
|
event: event.name,
|
||||||
@@ -26,17 +62,43 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
|||||||
enabled: !!event.name,
|
enabled: !!event.name,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<Combobox
|
if (!open) {
|
||||||
searchable
|
setState('index');
|
||||||
placeholder="Select a filter"
|
}
|
||||||
value=""
|
}, [open]);
|
||||||
items={properties.map((item) => ({
|
|
||||||
label: item,
|
// Mock data for the lists
|
||||||
value: item,
|
const profileActions = properties
|
||||||
}))}
|
.filter((property) => property.startsWith('profile'))
|
||||||
onChange={(value) => {
|
.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(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
@@ -44,21 +106,175 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
|||||||
...event.filters,
|
...event.filters,
|
||||||
{
|
{
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
name: value,
|
name: action.value,
|
||||||
operator: 'is',
|
operator: 'is',
|
||||||
value: [],
|
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 (
|
||||||
|
<DropdownMenu
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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
|
<FilterIcon size={12} /> Add filter
|
||||||
</button>
|
</button>
|
||||||
</Combobox>
|
</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) {
|
export function FiltersList({ event }: ReportEventFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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) => {
|
{event.filters.map((filter) => {
|
||||||
return <FilterItem key={filter.name} filter={filter} event={event} />;
|
return <FilterItem key={filter.name} filter={filter} event={event} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -85,13 +85,13 @@ export function getChartSql({
|
|||||||
sb.select.label_0 = `'*' as label_0`;
|
sb.select.label_0 = `'*' as label_0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// const anyFilterOnProfile = event.filters.some((filter) =>
|
const anyFilterOnProfile = event.filters.some((filter) =>
|
||||||
// filter.name.startsWith('profile.properties.'),
|
filter.name.startsWith('profile.'),
|
||||||
// );
|
);
|
||||||
|
|
||||||
// if (anyFilterOnProfile) {
|
if (anyFilterOnProfile) {
|
||||||
// sb.joins.profiles = 'JOIN profiles profile ON e.profile_id = profile.id';
|
sb.joins.profiles = `LEFT ANY JOIN (SELECT * FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${escape(projectId)}) as profile on profile.id = profile_id`;
|
||||||
// }
|
}
|
||||||
|
|
||||||
sb.select.count = 'count(*) as count';
|
sb.select.count = 'count(*) as count';
|
||||||
switch (interval) {
|
switch (interval) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { escape } from 'sqlstring';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type IServiceProfile,
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
|
ch,
|
||||||
chQuery,
|
chQuery,
|
||||||
|
clix,
|
||||||
conversionService,
|
conversionService,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
@@ -77,6 +80,24 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input: { projectId, event } }) => {
|
.query(async ({ input: { projectId, event } }) => {
|
||||||
|
const profiles = await clix(ch)
|
||||||
|
.select<Pick<IServiceProfile, 'properties'>>(['properties'])
|
||||||
|
.from(TABLE_NAMES.profiles)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('is_external', '=', true)
|
||||||
|
.orderBy('created_at', 'DESC')
|
||||||
|
.limit(100)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const profileProperties: string[] = [];
|
||||||
|
for (const p of profiles) {
|
||||||
|
for (const property of Object.keys(p.properties)) {
|
||||||
|
if (!profileProperties.includes(`profile.properties.${property}`)) {
|
||||||
|
profileProperties.push(`profile.properties.${property}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await chQuery<{ property_key: string; created_at: string }>(
|
const res = await chQuery<{ property_key: string; created_at: string }>(
|
||||||
`SELECT
|
`SELECT
|
||||||
distinct property_key,
|
distinct property_key,
|
||||||
@@ -116,6 +137,11 @@ export const chartRouter = createTRPCRouter({
|
|||||||
'device',
|
'device',
|
||||||
'brand',
|
'brand',
|
||||||
'model',
|
'model',
|
||||||
|
'profile.id',
|
||||||
|
'profile.first_name',
|
||||||
|
'profile.last_name',
|
||||||
|
'profile.email',
|
||||||
|
...profileProperties,
|
||||||
);
|
);
|
||||||
|
|
||||||
return pipe(
|
return pipe(
|
||||||
|
|||||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -208,6 +208,15 @@ importers:
|
|||||||
'@clickhouse/client':
|
'@clickhouse/client':
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@18.2.0)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^3.3.4
|
specifier: ^3.3.4
|
||||||
version: 3.3.4(react-hook-form@7.50.1(react@18.2.0))
|
version: 3.3.4(react-hook-form@7.50.1(react@18.2.0))
|
||||||
@@ -2428,6 +2437,28 @@ packages:
|
|||||||
'@dabh/diagnostics@2.0.3':
|
'@dabh/diagnostics@2.0.3':
|
||||||
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/core@1.3.1':
|
'@emnapi/core@1.3.1':
|
||||||
resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==}
|
resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==}
|
||||||
|
|
||||||
@@ -13127,6 +13158,31 @@ snapshots:
|
|||||||
enabled: 2.0.0
|
enabled: 2.0.0
|
||||||
kuler: 2.0.0
|
kuler: 2.0.0
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.7.0
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
tslib: 2.7.0
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.7.0
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.7.0
|
||||||
|
|
||||||
'@emnapi/core@1.3.1':
|
'@emnapi/core@1.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.0.1
|
'@emnapi/wasi-threads': 1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user