improve(dashboard): better event selector and other improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-06-05 11:28:06 +02:00
parent cd5dce02b8
commit f59bcfba3c
17 changed files with 470 additions and 145 deletions

View File

@@ -13,13 +13,17 @@ export default function LayoutContent({
const segments = useSelectedLayoutSegments(); const segments = useSelectedLayoutSegments();
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) { if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
return <div className="pb-20 transition-all lg:pl-72">{children}</div>; return (
<div className="pb-20 transition-all lg:pl-72 max-w-screen-2xl">
{children}
</div>
);
} }
return ( return (
<div <div
className={cn( className={cn(
'pb-20 transition-all max-lg:mt-12 lg:pl-72', 'pb-20 transition-all max-lg:mt-12 lg:pl-72 max-w-screen-2xl',
segments.includes('chat') && 'pb-0', segments.includes('chat') && 'pb-0',
)} )}
> >

View File

@@ -53,7 +53,7 @@ export default async function Page({
<SettingsToggle /> <SettingsToggle />
</div> </div>
</FullWidthNavbar> </FullWidthNavbar>
<div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:max-w-[95vw] lg:max-w-[80vw] "> <div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:w-[95vw] lg:w-[80vw] max-w-screen-2xl">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{projects.map((item) => ( {projects.map((item) => (
<ProjectCard key={item.id} {...item} /> <ProjectCard key={item.id} {...item} />

View File

@@ -9,6 +9,7 @@ import type { EventMeta } from '@openpanel/db';
const variants = cva('flex shrink-0 items-center justify-center rounded-full', { const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
variants: { variants: {
size: { size: {
xs: 'h-5 w-5',
sm: 'h-6 w-6', sm: 'h-6 w-6',
default: 'h-10 w-10', default: 'h-10 w-10',
}, },
@@ -96,6 +97,98 @@ export const EventIconMapper: Record<string, LucideIcon> = {
RepeatIcon: Icons.RepeatIcon, RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon, ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon, ExternalLinkIcon: Icons.ExternalLinkIcon,
UserIcon: Icons.UserIcon,
UsersIcon: Icons.UsersIcon,
UserPlusIcon: Icons.UserPlusIcon,
UserMinusIcon: Icons.UserMinusIcon,
UserCheckIcon: Icons.UserCheckIcon,
UserXIcon: Icons.UserXIcon,
PlayIcon: Icons.PlayIcon,
PauseIcon: Icons.PauseIcon,
SkipForwardIcon: Icons.SkipForwardIcon,
SkipBackIcon: Icons.SkipBackIcon,
VolumeIcon: Icons.VolumeIcon,
VolumeOffIcon: Icons.VolumeOffIcon,
ImageIcon: Icons.ImageIcon,
VideoIcon: Icons.VideoIcon,
MusicIcon: Icons.MusicIcon,
CameraIcon: Icons.CameraIcon,
ClickIcon: Icons.MousePointerClickIcon,
ChevronDownIcon: Icons.ChevronDownIcon,
ChevronUpIcon: Icons.ChevronUpIcon,
ChevronLeftIcon: Icons.ChevronLeftIcon,
ChevronRightIcon: Icons.ChevronRightIcon,
ArrowUpIcon: Icons.ArrowUpIcon,
ArrowDownIcon: Icons.ArrowDownIcon,
ArrowLeftIcon: Icons.ArrowLeftIcon,
ArrowRightIcon: Icons.ArrowRightIcon,
PhoneIcon: Icons.PhoneIcon,
MessageSquareIcon: Icons.MessageSquareIcon,
SendIcon: Icons.SendIcon,
ShoppingCartIcon: Icons.ShoppingCartIcon,
ShoppingBagIcon: Icons.ShoppingBagIcon,
CreditCardIcon: Icons.CreditCardIcon,
DollarSignIcon: Icons.DollarSignIcon,
EuroIcon: Icons.EuroIcon,
HeartIcon: Icons.HeartIcon,
StarIcon: Icons.StarIcon,
ThumbsUpIcon: Icons.ThumbsUpIcon,
ThumbsDownIcon: Icons.ThumbsDownIcon,
SmileIcon: Icons.SmileIcon,
FrownIcon: Icons.FrownIcon,
BarChartIcon: Icons.BarChartIcon,
LineChartIcon: Icons.LineChartIcon,
PieChartIcon: Icons.PieChartIcon,
TrendingUpIcon: Icons.TrendingUpIcon,
TrendingDownIcon: Icons.TrendingDownIcon,
TargetIcon: Icons.TargetIcon,
ShieldIcon: Icons.ShieldIcon,
EyeIcon: Icons.EyeIcon,
EyeOffIcon: Icons.EyeOffIcon,
KeyIcon: Icons.KeyIcon,
UnlockIcon: Icons.UnlockIcon,
SettingsIcon: Icons.SettingsIcon,
RefreshCwIcon: Icons.RefreshCwIcon,
TrashIcon: Icons.TrashIcon,
EditIcon: Icons.EditIcon,
PlusIcon: Icons.PlusIcon,
MinusIcon: Icons.MinusIcon,
XIcon: Icons.XIcon,
CheckIcon: Icons.CheckIcon,
SaveIcon: Icons.SaveIcon,
UploadIcon: Icons.UploadIcon,
SmartphoneIcon: Icons.SmartphoneIcon,
TabletIcon: Icons.TabletIcon,
LaptopIcon: Icons.LaptopIcon,
MonitorIcon: Icons.MonitorIcon,
WifiIcon: Icons.WifiIcon,
MapPinIcon: Icons.MapPinIcon,
NavigationIcon: Icons.NavigationIcon,
CompassIcon: Icons.CompassIcon,
FolderIcon: Icons.FolderIcon,
FileTextIcon: Icons.FileTextIcon,
FilePlusIcon: Icons.FilePlusIcon,
FileMinusIcon: Icons.FileMinusIcon,
DatabaseIcon: Icons.DatabaseIcon,
AlertCircleIcon: Icons.AlertCircleIcon,
InfoIcon: Icons.InfoIcon,
HelpCircleIcon: Icons.HelpCircleIcon,
CheckCircleIcon: Icons.CheckCircleIcon,
XCircleIcon: Icons.XCircleIcon,
CalendarDaysIcon: Icons.CalendarDaysIcon,
CalendarPlusIcon: Icons.CalendarPlusIcon,
TimerIcon: Icons.TimerIcon,
FilterIcon: Icons.FilterIcon,
SortAscIcon: Icons.ArrowUpAZIcon,
SortDescIcon: Icons.ArrowDownZAIcon,
CopyIcon: Icons.CopyIcon,
LinkIcon: Icons.LinkIcon,
QrCodeIcon: Icons.QrCodeIcon,
ScanIcon: Icons.ScanIcon,
ZapIcon: Icons.ZapIcon,
FlameIcon: Icons.FlameIcon,
RocketIcon: Icons.RocketIcon,
TrophyIcon: Icons.TrophyIcon,
}; };
export const EventIconColors = [ export const EventIconColors = [
@@ -139,7 +232,10 @@ export function EventIcon({ className, name, size, meta }: EventIconProps) {
return ( return (
<div className={cn(`bg-${color}-200`, variants({ size }), className)}> <div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon size={size === 'sm' ? 14 : 20} className={`text-${color}-700`} /> <Icon
size={size === 'xs' ? 12 : size === 'sm' ? 14 : 20}
className={`text-${color}-700`}
/>
</div> </div>
); );
} }

View File

@@ -12,7 +12,7 @@ type Props = {
const FullWidthNavbar = ({ children, className }: Props) => { const FullWidthNavbar = ({ children, className }: Props) => {
return ( return (
<div className={cn('border-b border-border bg-card', className)}> <div className={cn('border-b border-border bg-card', className)}>
<div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:max-w-[95vw] lg:max-w-[80vw]"> <div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:w-[95vw] lg:w-[80vw] max-w-screen-2xl">
<LogoSquare className="size-8" /> <LogoSquare className="size-8" />
{children} {children}
</div> </div>

View File

@@ -21,6 +21,7 @@ import type {
IChartEventFilterValue, IChartEventFilterValue,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { useOverviewOptions } from '../useOverviewOptions'; import { useOverviewOptions } from '../useOverviewOptions';
import { OriginFilter } from './origin-filter'; import { OriginFilter } from './origin-filter';
@@ -41,7 +42,6 @@ export function OverviewFiltersDrawerContent({
enableEventsFilter, enableEventsFilter,
mode, mode,
}: OverviewFiltersDrawerContentProps) { }: OverviewFiltersDrawerContentProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(nuqsOptions); const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions); const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId }); const eventNames = useEventNames({ projectId });
@@ -59,18 +59,16 @@ export function OverviewFiltersDrawerContent({
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<OriginFilter /> <OriginFilter />
{enableEventsFilter && ( {enableEventsFilter && (
<ComboboxAdvanced <ComboboxEvents
className="w-full" className="w-full"
value={event} value={event}
onChange={setEvent} onChange={setEvent}
// First items is * which is only used for report editing multiple
items={eventNames items={eventNames.filter(
.filter((item) => !excludePropertyFilter(item.name)) (item) => !excludePropertyFilter(item.name),
.map((item) => ({ )}
label: item.name,
value: item.name,
}))}
placeholder="Select event" placeholder="Select event"
maxDisplayItems={2}
/> />
)} )}
<Combobox <Combobox

View File

@@ -300,7 +300,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
return ( return (
<TooltipProvider data={data.current}> <TooltipProvider data={data.current}>
<div className="aspect-video max-h-[100px] w-full p-4 card pb-1"> <div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={rechartData}> <LineChart data={rechartData}>
<CartesianGrid <CartesianGrid

View File

@@ -60,7 +60,7 @@ export function ReportSegment({
<Button <Button
variant="outline" variant="outline"
icon={Icons[value]} icon={Icons[value]}
className={cn('justify-start', className)} className={cn('justify-start text-sm', className)}
> >
{items.find((item) => item.value === value)?.label} {items.find((item) => item.value === value)?.label}
</Button> </Button>

View File

@@ -94,6 +94,17 @@ export const reportSlice = createSlice({
...action.payload, ...action.payload,
}); });
}, },
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
});
},
removeEvent: ( removeEvent: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
@@ -270,6 +281,7 @@ export const {
setName, setName,
addEvent, addEvent,
removeEvent, removeEvent,
duplicateEvent,
changeEvent, changeEvent,
addBreakdown, addBreakdown,
removeBreakdown, removeBreakdown,

View File

@@ -1,40 +1,17 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Filter, MoreHorizontal, Tags, Trash } from 'lucide-react'; import { CopyIcon, MoreHorizontal, TrashIcon } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
const labels = [
'feature',
'bug',
'enhancement',
'documentation',
'design',
'question',
'maintenance',
];
export interface ReportEventMoreProps { export interface ReportEventMoreProps {
onClick: (action: 'remove') => void; onClick: (action: 'remove' | 'duplicate') => void;
} }
export function ReportEventMore({ onClick }: ReportEventMoreProps) { export function ReportEventMore({ onClick }: ReportEventMoreProps) {
@@ -49,12 +26,16 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onClick('duplicate')}>
<CopyIcon className="mr-2 h-4 w-4" />
Duplicate
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-red-600" className="text-red-600"
onClick={() => onClick('remove')} onClick={() => onClick('remove')}
> >
<Trash className="mr-2 h-4 w-4" /> <TrashIcon className="mr-2 h-4 w-4" />
Delete Delete
<DropdownMenuShortcut></DropdownMenuShortcut> <DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox'; import { ComboboxEvents } from '@/components/ui/combobox-events';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
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';
@@ -27,11 +26,12 @@ import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common'; import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEvent } from '@openpanel/validation';
import { FilterIcon, GanttChartIcon, HandIcon } from 'lucide-react'; import { FilterIcon, HandIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment'; import { ReportSegment } from '../ReportSegment';
import { import {
addEvent, addEvent,
changeEvent, changeEvent,
duplicateEvent,
removeEvent, removeEvent,
reorderEvents, reorderEvents,
} from '../reportSlice'; } from '../reportSlice';
@@ -146,6 +146,7 @@ export function ReportEvents() {
const eventNames = useEventNames({ const eventNames = useEventNames({
projectId, projectId,
}); });
const showSegment = !['retention', 'funnel'].includes(chartType); const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType); const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType); const showDisplayNameInput = !['retention'].includes(chartType);
@@ -181,6 +182,9 @@ export function ReportEvents() {
case 'remove': { case 'remove': {
return dispatch(removeEvent(event)); return dispatch(removeEvent(event));
} }
case 'duplicate': {
return dispatch(duplicateEvent(event));
}
} }
}; };
@@ -211,54 +215,42 @@ export function ReportEvents() {
isSelectManyEvents={isSelectManyEvents} isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100" className="rounded-lg border bg-def-100"
> >
{isSelectManyEvents ? ( <ComboboxEvents
<ComboboxAdvanced className="flex-1"
className="flex-1" searchable
value={event.filters[0]?.value ?? []} multiple={isSelectManyEvents as false}
onChange={(value) => { value={
dispatch( (isSelectManyEvents
changeEvent({ ? (event.filters[0]?.value ?? [])
id: event.id, : event.name) as any
segment: 'user', }
filters: [ onChange={(value) => {
{ dispatch(
name: 'name', changeEvent(
operator: 'is', Array.isArray(value)
value: value, ? {
id: event.id,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
name: value,
filters: [],
}, },
], ),
name: '*', );
}), }}
); items={eventNames}
}} placeholder="Select event"
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 && ( {showDisplayNameInput && (
<Input <Input
placeholder={ placeholder={
@@ -280,9 +272,8 @@ export function ReportEvents() {
); );
})} })}
<Combobox <ComboboxEvents
disabled={isAddEventDisabled} disabled={isAddEventDisabled}
icon={GanttChartIcon}
value={''} value={''}
searchable searchable
onChange={(value) => { onChange={(value) => {
@@ -310,11 +301,8 @@ export function ReportEvents() {
); );
} }
}} }}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event" placeholder="Select event"
items={eventNames}
/> />
</div> </div>
</SortableContext> </SortableContext>

View File

@@ -6,6 +6,7 @@ interface TooltipCompleteProps {
content: React.ReactNode | string; content: React.ReactNode | string;
disabled?: boolean; disabled?: boolean;
side?: 'top' | 'right' | 'bottom' | 'left'; side?: 'top' | 'right' | 'bottom' | 'left';
delay?: number;
} }
export function TooltipComplete({ export function TooltipComplete({
@@ -13,9 +14,10 @@ export function TooltipComplete({
disabled, disabled,
content, content,
side, side,
delay,
}: TooltipCompleteProps) { }: TooltipCompleteProps) {
return ( return (
<Tooltip> <Tooltip delayDuration={delay}>
<TooltipTrigger <TooltipTrigger
className="appearance-none" className="appearance-none"
style={{ textAlign: 'inherit' }} style={{ textAlign: 'inherit' }}

View File

@@ -0,0 +1,225 @@
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { PopoverPortal } from '@radix-ui/react-popover';
import type { LucideIcon } from 'lucide-react';
import { Check, CheckIcon, ChevronsUpDown, GanttChartIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import * as React from 'react';
import { EventIcon, EventIconMapper } from '../events/event-icon';
import { TooltipComplete } from '../tooltip-complete';
/**
* Type-safe ComboboxEvents component that supports both single and multiple selection.
*
* @example
* // Single selection mode (default)
* <ComboboxEvents<string>
* items={events}
* value={selectedEvent}
* onChange={(event: string) => setSelectedEvent(event)}
* placeholder="Select an event"
* />
*
* @example
* // Multiple selection mode
* <ComboboxEvents<string, true>
* items={events}
* value={selectedEvents}
* onChange={(events: string[]) => setSelectedEvents(events)}
* placeholder="Select events"
* multiple={true}
* />
*/
export interface ComboboxProps<T, TMultiple extends boolean = false> {
placeholder: string;
items: RouterOutputs['chart']['events'];
value: TMultiple extends true ? T[] : T | null | undefined;
onChange: TMultiple extends true ? (value: T[]) => void : (value: T) => void;
className?: string;
searchable?: boolean;
size?: ButtonProps['size'];
label?: string;
align?: 'start' | 'end' | 'center';
portal?: boolean;
error?: string;
disabled?: boolean;
multiple?: TMultiple;
maxDisplayItems?: number;
}
export function ComboboxEvents<
T extends string,
TMultiple extends boolean = false,
>({
placeholder,
value,
onChange,
className,
searchable,
size,
align = 'start',
portal,
error,
disabled,
items,
multiple = false as TMultiple,
maxDisplayItems = 2,
}: ComboboxProps<T, TMultiple>) {
const number = useNumber();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const selectedValues = React.useMemo((): T[] => {
if (multiple) {
return Array.isArray(value) ? (value as T[]) : value ? [value as T] : [];
}
return value ? [value as T] : [];
}, [value, multiple]);
function find(value: string) {
return items.find(
(item) => item.name.toLowerCase() === value.toLowerCase(),
);
}
const current =
selectedValues.length > 0 && selectedValues[0]
? find(selectedValues[0])
: null;
const handleSelection = (selectedValue: string) => {
if (multiple) {
const currentValues = selectedValues;
const newValues = currentValues.includes(selectedValue as T)
? currentValues.filter((v) => v !== selectedValue)
: [...currentValues, selectedValue as T];
onChange(newValues as any);
} else {
onChange(selectedValue as any);
setOpen(false);
}
};
const renderTriggerContent = () => {
if (selectedValues.length === 0) {
return placeholder;
}
const firstValue = selectedValues[0];
const item = firstValue ? find(firstValue) : null;
let label = item?.name || firstValue;
if (multiple && selectedValues.length > 1) {
label += ` +${selectedValues.length - 1}`;
}
return label;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
disabled={disabled}
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'justify-between',
!!error && 'border-destructive',
className,
)}
>
<div className="flex min-w-0 items-center">
{current?.meta ? (
<EventIcon
name={current.name}
meta={current.meta}
size="xs"
className="mr-2 shrink-0"
/>
) : (
<GanttChartIcon size={16} className="mr-2 shrink-0" />
)}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{renderTriggerContent()}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
className="w-full max-w-[33em] max-sm:max-w-[100vw] p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search event..."
value={search}
onValueChange={setSearch}
/>
)}
<CommandEmpty>Nothing selected</CommandEmpty>
<VirtualList
height={400}
data={items.filter((item) => {
if (search === '') return true;
return item.name.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="w-[33em] max-sm:max-w-[100vw]"
>
{(item) => {
return (
<CommandItem
className={cn(
'p-4 py-2.5 gap-4',
selectedValues.includes(item.name as T) && 'bg-accent',
)}
key={item.name}
value={item.name}
onSelect={(currentValue) => {
handleSelection(currentValue);
}}
>
{selectedValues.includes(item.name as T) ? (
<CheckIcon className="h-4 w-4 flex-shrink-0" />
) : (
<EventIcon name={item.name} meta={item.meta} size="sm" />
)}
<span className="font-medium flex-1 truncate">
{item.name === '*' ? 'Any events' : item.name}
</span>
<span className="text-muted-foreground font-mono font-medium">
{number.short(item.count)}
</span>
</CommandItem>
);
}}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -15,6 +15,7 @@ import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useEventNames } from '@/hooks/useEventNames'; import { useEventNames } from '@/hooks/useEventNames';
@@ -269,16 +270,13 @@ function EventField({
control={form.control} control={form.control}
name={`config.events.${index}.name`} name={`config.events.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<Combobox <ComboboxEvents
searchable searchable
className="flex-1" className="flex-1"
value={field.value} value={field.value}
placeholder="Select event" placeholder="Select event"
onChange={field.onChange} onChange={field.onChange}
items={eventNames.map((item) => ({ items={eventNames}
label: item.name,
value: item.name,
}))}
/> />
)} )}
/> />

View File

@@ -17,6 +17,7 @@ import { UndoIcon } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
@@ -60,6 +61,7 @@ export default function EditEvent({ id }: Props) {
const getBg = (color: string) => `bg-${color}-200`; const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`; const getText = (color: string) => `text-${color}-700`;
const iconGrid = 'grid grid-cols-10 gap-4'; const iconGrid = 'grid grid-cols-10 gap-4';
const [search, setSearch] = useState('');
return ( return (
<ModalContent> <ModalContent>
<ModalHeader <ModalHeader
@@ -92,26 +94,36 @@ export default function EditEvent({ id }: Props) {
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
> >
<Label className="mb-4 block">Pick an icon</Label> <Label className="mb-2 block">Pick an icon</Label>
<Input
className="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for an icon"
/>
<div className={iconGrid}> <div className={iconGrid}>
{Object.entries(EventIconMapper).map(([name, Icon]) => ( {Object.entries(EventIconMapper)
<button .filter(([name]) =>
type="button" name.toLowerCase().includes(search.toLowerCase()),
key={name} )
onClick={() => { .map(([name, Icon]) => (
setIcon(name); <button
setStep('color'); type="button"
}} key={name}
className={cn( onClick={() => {
'inline-flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-md bg-def-200 transition-all', setIcon(name);
name === selectedIcon setStep('color');
? 'scale-110 ring-1 ring-black' }}
: '[&_svg]:opacity-50', className={cn(
)} 'inline-flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-md bg-def-200 transition-all',
> name === selectedIcon
<Icon size={16} /> ? 'scale-110 ring-1 ring-black'
</button> : '[&_svg]:opacity-50',
))} )}
>
<Icon size={16} />
</button>
))}
</div> </div>
</motion.div> </motion.div>
) : ( ) : (
@@ -131,6 +143,7 @@ export default function EditEvent({ id }: Props) {
</Badge> </Badge>
</button> </button>
</div> </div>
<div className={iconGrid}> <div className={iconGrid}>
{EventIconColors.map((color) => ( {EventIconColors.map((color) => (
<button <button

View File

@@ -238,6 +238,16 @@ export function transformMinimalEvent(
}; };
} }
export function getEventMetas(projectId: string) {
return db.eventMeta.findMany({
where: {
projectId,
},
});
}
export const getEventMetasCached = cacheable(getEventMetas, 60 * 5);
export async function getEvents( export async function getEvents(
sql: string, sql: string,
options: GetEventsOptions = {}, options: GetEventsOptions = {},
@@ -261,17 +271,7 @@ export async function getEvents(
} }
if (options.meta && projectId) { if (options.meta && projectId) {
const metas = await getCache( const metas = await getEventMetasCached(projectId);
`event-metas-${projectId}`,
60 * 5,
async () => {
return db.eventMeta.findMany({
where: {
projectId,
},
});
},
);
const map = new Map<string, EventMeta>(); const map = new Map<string, EventMeta>();
for (const meta of metas) { for (const meta of metas) {
map.set(meta.name, meta); map.set(meta.name, meta);

View File

@@ -12,6 +12,7 @@ import {
createSqlBuilder, createSqlBuilder,
db, db,
funnelService, funnelService,
getEventMetasCached,
getSelectPropertyKey, getSelectPropertyKey,
getSettingsForProject, getSettingsForProject,
toDate, toDate,
@@ -61,15 +62,24 @@ export const chartRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input: { projectId } }) => { .query(async ({ input: { projectId } }) => {
const events = await chQuery<{ name: string }>( const [events, meta] = await Promise.all([
`SELECT DISTINCT name FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${escape(projectId)}`, chQuery<{ name: string; count: number }>(
); `SELECT name, count(name) as count FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${escape(projectId)} GROUP BY name ORDER BY count DESC, name ASC`,
),
getEventMetasCached(projectId),
]);
return [ return [
{ {
name: '*', name: '*',
count: events.reduce((acc, event) => acc + event.count, 0),
meta: undefined,
}, },
...events, ...events.map((event) => ({
name: event.name,
count: event.count,
meta: meta.find((m) => m.name === event.name),
})),
]; ];
}), }),

View File

@@ -10,7 +10,9 @@ import {
db, db,
eventService, eventService,
formatClickhouseDate, formatClickhouseDate,
getConversionEventNames,
getEventList, getEventList,
getEventMetasCached,
getEvents, getEvents,
getSettingsForProject, getSettingsForProject,
overviewService, overviewService,
@@ -41,6 +43,7 @@ export const eventRouter = createTRPCRouter({
) )
.mutation( .mutation(
async ({ input: { projectId, name, icon, color, conversion } }) => { async ({ input: { projectId, name, icon, color, conversion } }) => {
await getEventMetasCached.clear(projectId);
return db.eventMeta.upsert({ return db.eventMeta.upsert({
where: { where: {
name_projectId: { name_projectId: {
@@ -173,12 +176,7 @@ export const eventRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input: { projectId, cursor } }) => { .query(async ({ input: { projectId, cursor } }) => {
const conversions = await db.eventMeta.findMany({ const conversions = await getConversionEventNames(projectId);
where: {
projectId,
conversion: true,
},
});
if (conversions.length === 0) { if (conversions.length === 0) {
return { return {