feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Wed Nov 26 12:32:40 2025 +0100 wip commit8cd3b89fa3Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:33:58 2025 +0100 funnel commit95af86dc44Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:23:25 2025 +0100 wip commit727a218e6bAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:26 2025 +0100 conversion wip commit958ba535d6Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:20 2025 +0100 wip commit3bbeb927ccAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 09:18:48 2025 +0100 wip commitd99335e2f4Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 18:08:10 2025 +0100 wip commit1fa61b1ae9Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 15:50:28 2025 +0100 ts commit548747d826Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:17:01 2025 +0100 fix typecheck events -> series commit7b18544085Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:06:46 2025 +0100 fix report table commit57697a5a39Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Sat Nov 22 00:05:13 2025 +0100 wip commit06fb6c4f3cAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Fri Nov 21 11:21:17 2025 +0100 wip commitdd71fd4e11Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Thu Nov 20 13:56:58 2025 +0100 formulas
This commit is contained in:
@@ -11,12 +11,14 @@ import {
|
||||
} from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
UnionOmit,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
import type { z } from 'zod';
|
||||
@@ -39,7 +41,7 @@ const initialState: InitialState = {
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
series: [],
|
||||
range: '30d',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
@@ -86,24 +88,34 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.name = action.payload;
|
||||
},
|
||||
// Events
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
// Series (Events and Formulas)
|
||||
addSerie: (
|
||||
state,
|
||||
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
state.series.push({
|
||||
id: shortId(),
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
if (action.payload.type === 'event') {
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
});
|
||||
} as IChartEventItem);
|
||||
} else {
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
}
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
@@ -112,13 +124,13 @@ export const reportSlice = createSlice({
|
||||
}>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.filter(
|
||||
(event) => event.id !== action.payload.id,
|
||||
);
|
||||
state.series = state.series.filter((event) => {
|
||||
return event.id !== action.payload.id;
|
||||
});
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
||||
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.map((event) => {
|
||||
state.series = state.series.map((event) => {
|
||||
if (event.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
@@ -265,9 +277,9 @@ export const reportSlice = createSlice({
|
||||
) {
|
||||
state.dirty = true;
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const [movedEvent] = state.events.splice(fromIndex, 1);
|
||||
const [movedEvent] = state.series.splice(fromIndex, 1);
|
||||
if (movedEvent) {
|
||||
state.events.splice(toIndex, 0, movedEvent);
|
||||
state.series.splice(toIndex, 0, movedEvent);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -279,7 +291,7 @@ export const {
|
||||
ready,
|
||||
setReport,
|
||||
setName,
|
||||
addEvent,
|
||||
addSerie,
|
||||
removeEvent,
|
||||
duplicateEvent,
|
||||
changeEvent,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { api } from '@/trpc/client';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
|
||||
changeEvent({
|
||||
...event,
|
||||
property: value,
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
@@ -23,11 +25,11 @@ 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 { IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addEvent,
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
@@ -47,7 +49,7 @@ function SortableEvent({
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEvent;
|
||||
event: IChartEventItem;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
@@ -62,6 +64,8 @@ function SortableEvent({
|
||||
transition,
|
||||
};
|
||||
|
||||
const isEvent = event.type === 'event';
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
@@ -76,8 +80,8 @@ function SortableEvent({
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
{(showSegment || showAddFilter) && (
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{isEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
@@ -130,14 +134,14 @@ function SortableEvent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
{/* Filters - only for events */}
|
||||
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportEvents() {
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const selectedEvents = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
@@ -151,7 +155,7 @@ export function ReportEvents() {
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedEvents.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
@@ -174,11 +178,15 @@ export function ReportEvents() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const handleMore = (event: IChartEventItem) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: event.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
return dispatch(duplicateEvent(event));
|
||||
@@ -189,20 +197,31 @@ 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: e.id! }))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
@@ -213,95 +232,151 @@ export function ReportEvents() {
|
||||
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,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B, A/B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: '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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
<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(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportFormula() {
|
||||
const formula = useSelector((state) => state.report.formula);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Formula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputEnter
|
||||
placeholder="eg: A/B"
|
||||
value={formula ?? ''}
|
||||
onChangeValue={(value) => {
|
||||
dispatch(changeFormula(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
function SortableSeries({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
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: 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">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportSeries() {
|
||||
const selectedSeries = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedSeries.findIndex((e) => e.id === active.id);
|
||||
const newIndex = selectedSeries.findIndex((e) => e.id === over.id);
|
||||
|
||||
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: 'type' in event ? event.id : (event as IChartEvent).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
const normalized =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedSeries.map((e) => ({
|
||||
id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '',
|
||||
}))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedSeries.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Name: Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).filters[0]?.value ?? [])
|
||||
: (
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(event as IChartEventItem & { type: 'event' }).name
|
||||
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={
|
||||
(event as IChartEventItem & { type: 'event' })
|
||||
.displayName
|
||||
}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,23 +3,17 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
||||
import { useSelector } from '@/redux';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportFormula } from './ReportFormula';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
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 />
|
||||
<ReportSeries />
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
{showFormula && <ReportFormula />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
<SheetFooter>
|
||||
|
||||
@@ -39,14 +39,12 @@ interface PureFilterProps {
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
// const { range, startDate, endDate, interval } = useSelector(
|
||||
// (state) => state.report,
|
||||
// );
|
||||
const onRemove = ({ id }: IChartEventFilter) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.filter((item) => item.id !== id),
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
type: 'event',
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
@@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
type: 'event',
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user