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