This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-20 13:56:58 +01:00
parent 00e25ed4b8
commit dd71fd4e11
15 changed files with 1357 additions and 190 deletions

View File

@@ -8,7 +8,14 @@ import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UsersIcon, BookmarkIcon } from 'lucide-react';
import {
CartesianGrid,
ComposedChart,
@@ -44,10 +51,14 @@ export function Chart({ data }: Props) {
endDate,
range,
lineType,
events,
breakdowns,
},
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null);
const dataLength = data.series[0]?.data?.length || 0;
const trpc = useTRPC();
const references = useQuery(
@@ -130,18 +141,95 @@ export function Chart({ data }: Props) {
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
const payload = e.activePayload[0].payload;
const activeCoordinate = e.activeCoordinate;
if (payload.date) {
setClickedData({
date: payload.date,
serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''),
});
setClickPosition({
x: activeCoordinate?.x ?? 0,
y: activeCoordinate?.y ?? 0,
});
}
}
}, []);
const handleViewUsers = useCallback(() => {
if (!clickedData || !projectId || !startDate || !endDate) return;
// Find the event for the clicked serie
const serie = series.find((s) => s.id === clickedData.serieId);
const event = events.find((e) => {
const normalized = 'type' in e ? e : { ...e, type: 'event' as const };
if (normalized.type === 'event') {
return serie?.event.id === normalized.id || serie?.event.name === normalized.name;
}
return false;
});
if (event) {
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
if (normalized.type === 'event') {
pushModal('ViewChartUsers', {
projectId,
event: normalized,
date: clickedData.date,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
filters: normalized.filters || [],
});
}
}
setClickPosition(null);
setClickedData(null);
}, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]);
const handleAddReference = useCallback(() => {
if (!clickedData) return;
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
setClickPosition(null);
setClickedData(null);
}, [clickedData]);
return (
<ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<DropdownMenu
open={clickPosition !== null}
onOpenChange={(open) => {
if (!open) {
setClickPosition(null);
setClickedData(null);
}
}}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'absolute',
left: clickPosition?.x ?? -9999,
top: clickPosition?.y ?? -9999,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleViewUsers}>
<UsersIcon size={16} className="mr-2" />
View Users
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddReference}>
<BookmarkIcon size={16} className="mr-2" />
Add Reference
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ResponsiveContainer>
<ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} />

View File

@@ -12,6 +12,8 @@ import {
import type {
IChartBreakdown,
IChartEvent,
IChartEventItem,
IChartFormula,
IChartLineType,
IChartProps,
IChartRange,
@@ -86,24 +88,45 @@ export const reportSlice = createSlice({
state.dirty = true;
state.name = action.payload;
},
// Events
// Events and Formulas
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
id: shortId(),
type: 'event',
...action.payload,
});
} as IChartEventItem);
},
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
addFormula: (
state,
action: PayloadAction<Omit<IChartFormula, 'id'>>,
) => {
state.dirty = true;
state.events.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
});
...action.payload,
} as IChartEventItem);
},
duplicateEvent: (
state,
action: PayloadAction<IChartEventItem>,
) => {
state.dirty = true;
if (action.payload.type === 'event') {
state.events.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
} as IChartEventItem);
} else {
state.events.push({
...action.payload,
id: shortId(),
} as IChartEventItem);
}
},
removeEvent: (
state,
@@ -113,13 +136,18 @@ export const reportSlice = createSlice({
) => {
state.dirty = true;
state.events = state.events.filter(
(event) => event.id !== action.payload.id,
(event) => {
// Handle both old format (no type) and new format
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
return eventId !== action.payload.id;
},
);
},
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
state.events = state.events.map((event) => {
if (event.id === action.payload.id) {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) {
return action.payload;
}
return event;
@@ -280,6 +308,7 @@ export const {
setReport,
setName,
addEvent,
addFormula,
removeEvent,
duplicateEvent,
changeEvent,

View File

@@ -23,16 +23,19 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation';
import { FilterIcon, HandIcon } from 'lucide-react';
import type { IChartEvent, IChartEventItem, IChartFormula } from '@openpanel/validation';
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
changeEvent,
duplicateEvent,
removeEvent,
reorderEvents,
} from '../reportSlice';
import { InputEnter } from '@/components/ui/input-enter';
import { Button } from '@/components/ui/button';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
@@ -47,21 +50,29 @@ function SortableEvent({
isSelectManyEvents,
...props
}: {
event: IChartEvent;
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: event.id ?? '' });
useSortable({ id: eventId ?? '' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula ? null : (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
@@ -76,16 +87,16 @@ function SortableEvent({
{props.children}
</div>
{/* Segment and Filter buttons */}
{(showSegment || showAddFilter) && (
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={event.segment}
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...event,
...chartEvent,
segment,
}),
);
@@ -94,13 +105,13 @@ function SortableEvent({
)}
{showAddFilter && (
<PropertiesCombobox
event={event}
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...event,
...chartEvent,
filters: [
...event.filters,
...chartEvent.filters,
{
id: shortId(),
name: action.value,
@@ -124,14 +135,14 @@ function SortableEvent({
</PropertiesCombobox>
)}
{showSegment && event.segment.startsWith('property_') && (
<EventPropertiesCombobox event={event} />
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters */}
{!isSelectManyEvents && <FiltersList event={event} />}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
</div>
);
}
@@ -174,14 +185,15 @@ export function ReportEvents() {
}
};
const handleMore = (event: IChartEvent) => {
const handleMore = (event: IChartEventItem | IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeEvent(event));
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
}
case 'duplicate': {
return dispatch(duplicateEvent(event));
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
}
}
};
@@ -189,119 +201,184 @@ export function ReportEvents() {
return callback;
};
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
return (
<div>
<h3 className="mb-2 font-medium">Events</h3>
<h3 className="mb-2 font-medium">Metrics</h3>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => {
// Normalize event to have type field
const normalized: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalized.type === 'formula';
return (
<SortableEvent
key={event.id}
event={event}
key={normalized.id}
event={normalized}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? (event.filters[0]?.value ?? [])
: event.name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
{isFormula ? (
<>
<div className="flex-1 flex flex-col gap-2">
<InputEnter
placeholder="eg: A+B, A/B"
value={normalized.formula}
onChangeValue={(value) => {
dispatchChangeFormula({
...normalized,
formula: value,
});
}}
/>
{showDisplayNameInput && (
<Input
placeholder={`Formula (${alphabetIds[index]})`}
defaultValue={normalized.displayName}
onChange={(e) => {
dispatchChangeFormula({
...normalized,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(normalized)} />
</>
) : (
<>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
: (normalized as IChartEventItem & { type: 'event' }).name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: normalized.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...normalized,
type: 'event',
name: value,
filters: [],
},
],
name: '*',
}
: {
...event,
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(normalized as IChartEventItem & { type: 'event' }).name
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
onChange={(e) => {
dispatchChangeEvent({
...(normalized as IChartEventItem & { type: 'event' }),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(normalized)} />
</>
)}
<ReportEventMore onClick={handleMore(event)} />
</SortableEvent>
);
})}
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
<div className="flex gap-2">
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
{showFormula && (
<Button
type="button"
variant="outline"
icon={PlusIcon}
onClick={() => {
dispatch(
addFormula({
type: 'formula',
formula: '',
displayName: '',
}),
);
}}
>
Add Formula
</Button>
)}
</div>
</div>
</SortableContext>
</DndContext>

View File

@@ -9,17 +9,12 @@ import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
const showBreakdown = chartType !== 'retention';
return (
<>
<div className="flex flex-col gap-8">
<ReportEvents />
{showBreakdown && <ReportBreakdowns />}
{showFormula && <ReportFormula />}
<ReportSettings />
</div>
<SheetFooter>

View File

@@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan';
import ShareOverviewModal from './share-overview-modal';
import ViewChartUsers from './view-chart-users';
const modals = {
OverviewTopPagesModal: OverviewTopPagesModal,
@@ -51,6 +52,7 @@ const modals = {
EditReference: EditReference,
ShareOverviewModal: ShareOverviewModal,
AddReference: AddReference,
ViewChartUsers: ViewChartUsers,
Instructions: Instructions,
OnboardingTroubleshoot: OnboardingTroubleshoot,
DateRangerPicker: DateRangerPicker,

View File

@@ -0,0 +1,112 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import type { IChartEvent } from '@openpanel/validation';
interface ViewChartUsersProps {
projectId: string;
event: IChartEvent;
date: string;
breakdowns?: Array<{ id?: string; name: string }>;
interval: string;
startDate: string;
endDate: string;
filters?: Array<{
id?: string;
name: string;
operator: string;
value: Array<string | number | boolean | null>;
}>;
}
export default function ViewChartUsers({
projectId,
event,
date,
breakdowns = [],
interval,
startDate,
endDate,
filters = [],
}: ViewChartUsersProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.chart.getProfiles.queryOptions({
projectId,
event,
date,
breakdowns,
interval: interval as any,
startDate,
endDate,
filters,
}),
);
const profiles = query.data ?? [];
return (
<ModalContent>
<ModalHeader
title="View Users"
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`}
/>
<div className="flex flex-col gap-4">
{query.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : profiles.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">No users found</div>
</div>
) : (
<div className="max-h-[60vh] overflow-y-auto">
<div className="flex flex-col gap-2">
{profiles.map((profile) => (
<div
key={profile.id}
className="flex items-center gap-3 rounded-lg border p-3"
>
{profile.avatar ? (
<img
src={profile.avatar}
alt={profile.firstName || profile.email}
className="size-10 rounded-full"
/>
) : (
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
<UsersIcon size={20} />
</div>
)}
<div className="flex-1">
<div className="font-medium">
{profile.firstName || profile.lastName
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
: profile.email || 'Anonymous'}
</div>
{profile.email && (
<div className="text-sm text-muted-foreground">
{profile.email}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Close
</Button>
</ButtonContainer>
</div>
</ModalContent>
);
}