improve(dashboard): better event selector and other improvements
This commit is contained in:
@@ -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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,13 +215,20 @@ 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"
|
||||||
value={event.filters[0]?.value ?? []}
|
searchable
|
||||||
|
multiple={isSelectManyEvents as false}
|
||||||
|
value={
|
||||||
|
(isSelectManyEvents
|
||||||
|
? (event.filters[0]?.value ?? [])
|
||||||
|
: event.name) as any
|
||||||
|
}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent(
|
||||||
|
Array.isArray(value)
|
||||||
|
? {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -228,37 +239,18 @@ export function ReportEvents() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
name: '*',
|
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,
|
...event,
|
||||||
name: value,
|
name: value,
|
||||||
filters: [],
|
filters: [],
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
items={eventNames.map((item) => ({
|
items={eventNames}
|
||||||
label: item.name,
|
|
||||||
value: item.name,
|
|
||||||
}))}
|
|
||||||
placeholder="Select event"
|
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>
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
225
apps/dashboard/src/components/ui/combobox-events.tsx
Normal file
225
apps/dashboard/src/components/ui/combobox-events.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,9 +94,19 @@ 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)
|
||||||
|
.filter(([name]) =>
|
||||||
|
name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map(([name, Icon]) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={name}
|
key={name}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user