feature(dashboard): filter on profile properties and support drag n drop for events

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-04-16 11:08:58 +02:00
parent 34769a5d58
commit be3c18b677
11 changed files with 658 additions and 300 deletions

View File

@@ -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:^",

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { Debug } from './Debug';
export default function Page() {
return <Debug />;
}

View File

@@ -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;

View File

@@ -2,135 +2,84 @@
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 */}
{(showSegment || showAddFilter) && ( {(showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0 "> <div className="flex gap-2 p-2 pt-0">
{showSegment && ( {showSegment && (
<DropdownMenuComposed <DropdownMenuComposed
onChange={(segment) => { onChange={(segment) => {
@@ -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>
); );
} }

View File

@@ -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"
/> />
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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} />;
})} })}

View File

@@ -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) {

View File

@@ -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
View File

@@ -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