This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-24 15:50:28 +01:00
parent 548747d826
commit 1fa61b1ae9
20 changed files with 321 additions and 295 deletions

View File

@@ -1,12 +1,4 @@
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow as UITableRow,
} from '@/components/ui/table';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSelector } from '@/redux';
@@ -28,8 +20,8 @@ import {
} from '@tanstack/react-virtual';
import throttle from 'lodash.throttle';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import type * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ReportTableToolbar } from './report-table-toolbar';
import {

View File

@@ -30,16 +30,7 @@ interface Props {
export function Chart({ data }: Props) {
const {
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
events,
},
report: { interval, projectId, startDate, endDate, range, lineType },
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();

View File

@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
previous?: ReportChartProps['report']['previous'];
chartType?: ReportChartProps['report']['chartType'];
interval?: ReportChartProps['report']['interval'];
events: ReportChartProps['report']['events'];
series: ReportChartProps['report']['series'];
breakdowns?: ReportChartProps['report']['breakdowns'];
lineType?: ReportChartProps['report']['lineType'];
};
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
previous = false,
chartType = 'linear',
interval = 'day',
events,
series,
breakdowns,
lineType = 'monotone',
options,
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
previous,
chartType,
interval,
events,
series,
lineType,
metric: 'sum',
}}

View File

@@ -11,7 +11,6 @@ import {
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartEventItem,
IChartFormula,
IChartLineType,
@@ -19,6 +18,7 @@ import type {
IChartRange,
IChartType,
IInterval,
UnionOmit,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
@@ -89,37 +89,26 @@ export const reportSlice = createSlice({
state.name = action.payload;
},
// Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.series.push({
id: shortId(),
type: 'event',
...action.payload,
} as IChartEventItem);
},
addFormula: (
addSerie: (
state,
action: PayloadAction<Omit<IChartFormula, 'id'>>,
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
) => {
state.dirty = true;
state.series.push({
id: shortId(),
...action.payload,
} as IChartEventItem);
});
},
duplicateEvent: (
state,
action: PayloadAction<IChartEventItem>,
) => {
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
if (action.payload.type === 'event') {
state.series.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
})),
id: shortId(),
} as IChartEventItem);
} else {
state.series.push({
@@ -135,19 +124,14 @@ export const reportSlice = createSlice({
}>,
) => {
state.dirty = true;
state.series = state.series.filter(
(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;
},
);
state.series = state.series.filter((event) => {
return event.id !== action.payload.id;
});
},
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
state.series = state.series.map((event) => {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
@@ -307,8 +291,7 @@ export const {
ready,
setReport,
setName,
addEvent,
addFormula,
addSerie,
removeEvent,
duplicateEvent,
changeEvent,

View File

@@ -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',
}),
);
}}

View File

@@ -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,19 +25,16 @@ import {
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 type { IChartEventItem, IChartFormula } from '@openpanel/validation';
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
addSerie,
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';
@@ -50,28 +49,22 @@ function SortableEvent({
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
event: IChartEventItem;
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 ?? '' });
useSortable({ id: event.id ?? '' });
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' });
const isEvent = event.type === 'event';
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
@@ -88,15 +81,15 @@ function SortableEvent({
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
{isEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
value={event.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
...event,
segment,
}),
);
@@ -105,13 +98,13 @@ function SortableEvent({
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
event={event}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
...event,
filters: [
...chartEvent.filters,
...event.filters,
{
id: shortId(),
name: action.value,
@@ -135,20 +128,20 @@ function SortableEvent({
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
{showSegment && event.segment.startsWith('property_') && (
<EventPropertiesCombobox event={event} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
{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();
@@ -162,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';
@@ -185,15 +178,18 @@ export function ReportEvents() {
}
};
const handleMore = (event: IChartEventItem | IChartEvent) => {
const handleMore = (event: IChartEventItem) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
return dispatch(
removeEvent({
id: event.id,
}),
);
}
case 'duplicate': {
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
return dispatch(duplicateEvent(event));
}
}
};
@@ -205,7 +201,10 @@ export function ReportEvents() {
dispatch(changeEvent(formula));
});
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return (
<div>
@@ -216,20 +215,17 @@ export function ReportEvents() {
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
items={selectedEvents.map((e) => ({ id: e.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';
const isFormula = event.type === 'formula';
return (
<SortableEvent
key={normalized.id}
event={normalized}
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
@@ -241,10 +237,10 @@ export function ReportEvents() {
<div className="flex-1 flex flex-col gap-2">
<InputEnter
placeholder="eg: A+B, A/B"
value={normalized.formula}
value={event.formula}
onChangeValue={(value) => {
dispatchChangeFormula({
...normalized,
...event,
formula: value,
});
}}
@@ -252,75 +248,75 @@ export function ReportEvents() {
{showDisplayNameInput && (
<Input
placeholder={`Formula (${alphabetIds[index]})`}
defaultValue={normalized.displayName}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeFormula({
...normalized,
...event,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(normalized)} />
<ReportEventMore onClick={handleMore(event)} />
</>
) : (
<>
<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,
<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,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
],
name: '*',
}
: {
...normalized,
type: 'event',
name: value,
filters: [],
},
),
);
}}
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)} />
),
);
}}
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)} />
</>
)}
</SortableEvent>
@@ -328,38 +324,40 @@ export function ReportEvents() {
})}
<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}
/>
<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"
@@ -367,7 +365,7 @@ export function ReportEvents() {
icon={PlusIcon}
onClick={() => {
dispatch(
addFormula({
addSerie({
type: 'formula',
formula: '',
displayName: '',

View File

@@ -30,11 +30,10 @@ import type {
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import { FilterIcon, HandIcon, PiIcon, PlusIcon } from 'lucide-react';
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
addSerie,
changeEvent,
duplicateEvent,
removeEvent,
@@ -168,7 +167,7 @@ export function ReportSeries() {
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
@@ -361,7 +360,8 @@ export function ReportSeries() {
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
addSerie({
type: 'event',
segment: 'user',
name: value,
filters: [
@@ -375,7 +375,8 @@ export function ReportSeries() {
);
} else {
dispatch(
addEvent({
addSerie({
type: 'event',
name: value,
segment: 'event',
filters: [],
@@ -393,7 +394,7 @@ export function ReportSeries() {
icon={PiIcon}
onClick={() => {
dispatch(
addFormula({
addSerie({
type: 'formula',
formula: '',
displayName: '',

View File

@@ -4,7 +4,6 @@ import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportSeries } from './ReportSeries';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {

View File

@@ -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 {

View File

@@ -56,9 +56,8 @@ export default function AddNotificationRule({ rule }: Props) {
template: rule?.template ?? '',
config: rule?.config ?? {
type: 'events',
series: [
events: [
{
type: 'event',
name: '',
segment: 'event',
filters: [],

View File

@@ -341,13 +341,14 @@ export default function EventDetails({ id, createdAt, projectId }: Props) {
<ReportChartShortcut
projectId={event.projectId}
chartType="linear"
events={[
series={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
type: 'event',
},
]}
/>

View File

@@ -9,7 +9,7 @@ import {
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import type { IChartEvent } from '@openpanel/validation';
import type { IChartEventItem } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router';
@@ -23,13 +23,14 @@ function Component() {
const { projectId } = Route.useParams();
const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter();
const fallback: IChartEvent[] = [
const fallback: IChartEventItem[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
];
@@ -49,7 +50,7 @@ function Component() {
projectId={projectId}
range="30d"
chartType="histogram"
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -57,6 +58,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: fallback
}
@@ -78,7 +80,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -86,6 +88,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -94,6 +97,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -115,7 +119,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -123,6 +127,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -131,6 +136,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -152,7 +158,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -160,6 +166,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -168,6 +175,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}