From f59bcfba3c5a0a97512f039a6cadeeed2d5667ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 5 Jun 2025 11:28:06 +0200 Subject: [PATCH] improve(dashboard): better event selector and other improvements --- .../[projectId]/layout-content.tsx | 8 +- .../src/app/(app)/[organizationSlug]/page.tsx | 2 +- .../src/components/events/event-icon.tsx | 98 +++++++- .../src/components/full-width-navbar.tsx | 2 +- .../overview-filters-drawer-content.tsx | 16 +- .../components/report-chart/funnel/chart.tsx | 2 +- .../src/components/report/ReportSegment.tsx | 2 +- .../src/components/report/reportSlice.ts | 12 + .../report/sidebar/ReportEventMore.tsx | 35 +-- .../report/sidebar/ReportEvents.tsx | 100 ++++---- .../src/components/tooltip-complete.tsx | 4 +- .../src/components/ui/combobox-events.tsx | 225 ++++++++++++++++++ .../src/modals/add-notification-rule.tsx | 8 +- apps/dashboard/src/modals/edit-event.tsx | 51 ++-- packages/db/src/services/event.service.ts | 22 +- packages/trpc/src/routers/chart.ts | 18 +- packages/trpc/src/routers/event.ts | 10 +- 17 files changed, 470 insertions(+), 145 deletions(-) create mode 100644 apps/dashboard/src/components/ui/combobox-events.tsx diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-content.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-content.tsx index dff7f706..316392aa 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-content.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-content.tsx @@ -13,13 +13,17 @@ export default function LayoutContent({ const segments = useSelectedLayoutSegments(); if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) { - return
{children}
; + return ( +
+ {children} +
+ ); } return (
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx index 57c21a52..39946c91 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx @@ -53,7 +53,7 @@ export default async function Page({
-
+
{projects.map((item) => ( diff --git a/apps/dashboard/src/components/events/event-icon.tsx b/apps/dashboard/src/components/events/event-icon.tsx index d8220eee..154206c1 100644 --- a/apps/dashboard/src/components/events/event-icon.tsx +++ b/apps/dashboard/src/components/events/event-icon.tsx @@ -9,6 +9,7 @@ import type { EventMeta } from '@openpanel/db'; const variants = cva('flex shrink-0 items-center justify-center rounded-full', { variants: { size: { + xs: 'h-5 w-5', sm: 'h-6 w-6', default: 'h-10 w-10', }, @@ -96,6 +97,98 @@ export const EventIconMapper: Record = { RepeatIcon: Icons.RepeatIcon, ShareIcon: Icons.ShareIcon, 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 = [ @@ -139,7 +232,10 @@ export function EventIcon({ className, name, size, meta }: EventIconProps) { return (
- +
); } diff --git a/apps/dashboard/src/components/full-width-navbar.tsx b/apps/dashboard/src/components/full-width-navbar.tsx index 63a01d56..493c79ef 100644 --- a/apps/dashboard/src/components/full-width-navbar.tsx +++ b/apps/dashboard/src/components/full-width-navbar.tsx @@ -12,7 +12,7 @@ type Props = { const FullWidthNavbar = ({ children, className }: Props) => { return (
-
+
{children}
diff --git a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx index fd3ed4dd..4da12bbf 100644 --- a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx +++ b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx @@ -21,6 +21,7 @@ import type { IChartEventFilterValue, } from '@openpanel/validation'; +import { ComboboxEvents } from '@/components/ui/combobox-events'; import { useOverviewOptions } from '../useOverviewOptions'; import { OriginFilter } from './origin-filter'; @@ -41,7 +42,6 @@ export function OverviewFiltersDrawerContent({ enableEventsFilter, mode, }: OverviewFiltersDrawerContentProps) { - const { interval, range, startDate, endDate } = useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(nuqsOptions); const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions); const eventNames = useEventNames({ projectId }); @@ -59,18 +59,16 @@ export function OverviewFiltersDrawerContent({
{enableEventsFilter && ( - !excludePropertyFilter(item.name)) - .map((item) => ({ - label: item.name, - value: item.name, - }))} + multiple + items={eventNames.filter( + (item) => !excludePropertyFilter(item.name), + )} placeholder="Select event" + maxDisplayItems={2} /> )} -
+
{items.find((item) => item.value === value)?.label} diff --git a/apps/dashboard/src/components/report/reportSlice.ts b/apps/dashboard/src/components/report/reportSlice.ts index 6e3f6f95..5a001a78 100644 --- a/apps/dashboard/src/components/report/reportSlice.ts +++ b/apps/dashboard/src/components/report/reportSlice.ts @@ -94,6 +94,17 @@ export const reportSlice = createSlice({ ...action.payload, }); }, + duplicateEvent: (state, action: PayloadAction>) => { + state.dirty = true; + state.events.push({ + ...action.payload, + filters: action.payload.filters.map((filter) => ({ + ...filter, + id: shortId(), + })), + id: shortId(), + }); + }, removeEvent: ( state, action: PayloadAction<{ @@ -270,6 +281,7 @@ export const { setName, addEvent, removeEvent, + duplicateEvent, changeEvent, addBreakdown, removeBreakdown, diff --git a/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx b/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx index 341ed243..bfc26ab1 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportEventMore.tsx @@ -1,40 +1,17 @@ import { Button } from '@/components/ui/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } 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'; -const labels = [ - 'feature', - 'bug', - 'enhancement', - 'documentation', - 'design', - 'question', - 'maintenance', -]; - export interface ReportEventMoreProps { - onClick: (action: 'remove') => void; + onClick: (action: 'remove' | 'duplicate') => void; } export function ReportEventMore({ onClick }: ReportEventMoreProps) { @@ -49,12 +26,16 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) { - + onClick('duplicate')}> + + Duplicate + ⌘⌫ + onClick('remove')} > - + Delete ⌘⌫ diff --git a/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx b/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx index 4990b2a7..7da7bdeb 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx @@ -1,8 +1,7 @@ 'use client'; import { ColorSquare } from '@/components/color-square'; -import { Combobox } from '@/components/ui/combobox'; -import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { ComboboxEvents } from '@/components/ui/combobox-events'; import { Input } from '@/components/ui/input'; import { useAppParams } from '@/hooks/useAppParams'; import { useDebounceFn } from '@/hooks/useDebounceFn'; @@ -27,11 +26,12 @@ import { CSS } from '@dnd-kit/utilities'; import { shortId } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; import type { IChartEvent } from '@openpanel/validation'; -import { FilterIcon, GanttChartIcon, HandIcon } from 'lucide-react'; +import { FilterIcon, HandIcon } from 'lucide-react'; import { ReportSegment } from '../ReportSegment'; import { addEvent, changeEvent, + duplicateEvent, removeEvent, reorderEvents, } from '../reportSlice'; @@ -146,6 +146,7 @@ export function ReportEvents() { const eventNames = useEventNames({ projectId, }); + const showSegment = !['retention', 'funnel'].includes(chartType); const showAddFilter = !['retention'].includes(chartType); const showDisplayNameInput = !['retention'].includes(chartType); @@ -181,6 +182,9 @@ export function ReportEvents() { case 'remove': { return dispatch(removeEvent(event)); } + case 'duplicate': { + return dispatch(duplicateEvent(event)); + } } }; @@ -211,54 +215,42 @@ export function ReportEvents() { isSelectManyEvents={isSelectManyEvents} className="rounded-lg border bg-def-100" > - {isSelectManyEvents ? ( - { - dispatch( - changeEvent({ - id: event.id, - segment: 'user', - filters: [ - { - name: 'name', - operator: 'is', - value: value, + { + dispatch( + changeEvent( + Array.isArray(value) + ? { + id: event.id, + segment: 'user', + filters: [ + { + name: 'name', + operator: 'is', + value: value, + }, + ], + name: '*', + } + : { + ...event, + name: value, + filters: [], }, - ], - name: '*', - }), - ); - }} - items={eventNames.map((item) => ({ - label: item.name, - value: item.name, - }))} - placeholder="Select event" - /> - ) : ( - { - dispatch( - changeEvent({ - ...event, - name: value, - filters: [], - }), - ); - }} - items={eventNames.map((item) => ({ - label: item.name, - value: item.name, - }))} - placeholder="Select event" - /> - )} + ), + ); + }} + items={eventNames} + placeholder="Select event" + /> {showDisplayNameInput && ( { @@ -310,11 +301,8 @@ export function ReportEvents() { ); } }} - items={eventNames.map((item) => ({ - label: item.name, - value: item.name, - }))} placeholder="Select event" + items={eventNames} />
diff --git a/apps/dashboard/src/components/tooltip-complete.tsx b/apps/dashboard/src/components/tooltip-complete.tsx index f17d2397..31fd0f63 100644 --- a/apps/dashboard/src/components/tooltip-complete.tsx +++ b/apps/dashboard/src/components/tooltip-complete.tsx @@ -6,6 +6,7 @@ interface TooltipCompleteProps { content: React.ReactNode | string; disabled?: boolean; side?: 'top' | 'right' | 'bottom' | 'left'; + delay?: number; } export function TooltipComplete({ @@ -13,9 +14,10 @@ export function TooltipComplete({ disabled, content, side, + delay, }: TooltipCompleteProps) { return ( - + + * items={events} + * value={selectedEvent} + * onChange={(event: string) => setSelectedEvent(event)} + * placeholder="Select an event" + * /> + * + * @example + * // Multiple selection mode + * + * items={events} + * value={selectedEvents} + * onChange={(events: string[]) => setSelectedEvents(events)} + * placeholder="Select events" + * multiple={true} + * /> + */ +export interface ComboboxProps { + 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) { + 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 ( + + + + + + + + {searchable === true && ( + + )} + + Nothing selected + { + 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 ( + { + handleSelection(currentValue); + }} + > + {selectedValues.includes(item.name as T) ? ( + + ) : ( + + )} + + {item.name === '*' ? 'Any events' : item.name} + + + {number.short(item.count)} + + + ); + }} + + + + + + ); +} diff --git a/apps/dashboard/src/modals/add-notification-rule.tsx b/apps/dashboard/src/modals/add-notification-rule.tsx index 9d8a2604..139d3ba5 100644 --- a/apps/dashboard/src/modals/add-notification-rule.tsx +++ b/apps/dashboard/src/modals/add-notification-rule.tsx @@ -15,6 +15,7 @@ import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem'; import { Button } from '@/components/ui/button'; import { Combobox } from '@/components/ui/combobox'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { ComboboxEvents } from '@/components/ui/combobox-events'; import { Textarea } from '@/components/ui/textarea'; import { useAppParams } from '@/hooks/useAppParams'; import { useEventNames } from '@/hooks/useEventNames'; @@ -269,16 +270,13 @@ function EventField({ control={form.control} name={`config.events.${index}.name`} render={({ field }) => ( - ({ - label: item.name, - value: item.name, - }))} + items={eventNames} /> )} /> diff --git a/apps/dashboard/src/modals/edit-event.tsx b/apps/dashboard/src/modals/edit-event.tsx index f8819c8a..5a246d43 100644 --- a/apps/dashboard/src/modals/edit-event.tsx +++ b/apps/dashboard/src/modals/edit-event.tsx @@ -17,6 +17,7 @@ import { UndoIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +import { Input } from '@/components/ui/input'; import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; @@ -60,6 +61,7 @@ export default function EditEvent({ id }: Props) { const getBg = (color: string) => `bg-${color}-200`; const getText = (color: string) => `text-${color}-700`; const iconGrid = 'grid grid-cols-10 gap-4'; + const [search, setSearch] = useState(''); return ( - + + setSearch(e.target.value)} + placeholder="Search for an icon" + />
- {Object.entries(EventIconMapper).map(([name, Icon]) => ( - - ))} + {Object.entries(EventIconMapper) + .filter(([name]) => + name.toLowerCase().includes(search.toLowerCase()), + ) + .map(([name, Icon]) => ( + + ))}
) : ( @@ -131,6 +143,7 @@ export default function EditEvent({ id }: Props) {
+
{EventIconColors.map((color) => (