ts
This commit is contained in:
@@ -13,11 +13,7 @@ import {
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import {
|
||||
zChartEvent,
|
||||
zChartInput,
|
||||
zChartInputBase,
|
||||
} from '@openpanel/validation';
|
||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
async function getProjectId(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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: '',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { shortId } from '@openpanel/common';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { db } from '../index';
|
||||
import { printBoxMessage } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
printBoxMessage('🔄 Migrating Events to Series Format', []);
|
||||
|
||||
// Get all reports
|
||||
const reports = await db.report.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
events: true,
|
||||
formula: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
let migratedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let formulaAddedCount = 0;
|
||||
|
||||
for (const report of reports) {
|
||||
const events = report.events as unknown as Array<
|
||||
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||
>;
|
||||
const oldFormula = report.formula;
|
||||
|
||||
// Check if any event is missing the 'type' field (old format)
|
||||
const needsEventMigration =
|
||||
Array.isArray(events) &&
|
||||
events.length > 0 &&
|
||||
events.some(
|
||||
(event) => !event || typeof event !== 'object' || !('type' in event),
|
||||
);
|
||||
|
||||
// Check if formula exists and isn't already in the series
|
||||
const hasFormulaInSeries =
|
||||
Array.isArray(events) &&
|
||||
events.some(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'type' in item &&
|
||||
item.type === 'formula',
|
||||
);
|
||||
|
||||
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
|
||||
|
||||
// Skip if no migration needed
|
||||
if (!needsEventMigration && !needsFormulaMigration) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform events to new format: add type: 'event' to each event
|
||||
const migratedSeries: IChartEventItem[] = Array.isArray(events)
|
||||
? events.map((event) => {
|
||||
if (event && typeof event === 'object' && 'type' in event) {
|
||||
return event as IChartEventItem;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
type: 'event',
|
||||
} as IChartEventItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
// Add formula to series if it exists and isn't already there
|
||||
if (needsFormulaMigration && oldFormula) {
|
||||
const formulaItem: IChartFormula = {
|
||||
type: 'formula',
|
||||
formula: oldFormula,
|
||||
id: shortId(),
|
||||
};
|
||||
migratedSeries.push(formulaItem);
|
||||
formulaAddedCount++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
|
||||
);
|
||||
// Update the report with migrated series
|
||||
await db.report.update({
|
||||
where: { id: report.id },
|
||||
data: {
|
||||
events: migratedSeries,
|
||||
},
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
|
||||
printBoxMessage('✅ Migration Complete', [
|
||||
`Migrated: ${migratedCount} reports`,
|
||||
`Formulas added: ${formulaAddedCount} reports`,
|
||||
`Skipped: ${skippedCount} reports (already in new format or empty)`,
|
||||
]);
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import { slug } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
} from '@openpanel/validation';
|
||||
import type { IChartEventItem } from '@openpanel/validation';
|
||||
import { getSettingsForProject } from '../services/organization.service';
|
||||
import type { ConcreteSeries, Plan } from './types';
|
||||
import type { NormalizedInput } from './normalize';
|
||||
import type { ConcreteSeries, Plan } from './types';
|
||||
|
||||
/**
|
||||
* Create an execution plan from normalized input
|
||||
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
|
||||
*/
|
||||
export async function plan(
|
||||
normalized: NormalizedInput,
|
||||
): Promise<Plan> {
|
||||
export async function plan(normalized: NormalizedInput): Promise<Plan> {
|
||||
const { timezone } = await getSettingsForProject(normalized.projectId);
|
||||
|
||||
const concreteSeries: ConcreteSeries[] = [];
|
||||
@@ -54,6 +48,3 @@ export async function plan(
|
||||
timezone,
|
||||
};
|
||||
}
|
||||
|
||||
export type NormalizedInput = Awaited<ReturnType<typeof import('./normalize').normalize>>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
import { onlyReportEvents } from './reports.service';
|
||||
|
||||
export class ConversionService {
|
||||
constructor(private client: typeof ch) {}
|
||||
@@ -18,7 +19,6 @@ export class ConversionService {
|
||||
funnelGroup,
|
||||
funnelWindow = 24,
|
||||
series,
|
||||
events, // Backward compatibility - use series if available
|
||||
breakdowns = [],
|
||||
interval,
|
||||
timezone,
|
||||
@@ -31,12 +31,9 @@ export class ConversionService {
|
||||
);
|
||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).filter(
|
||||
(item): item is IChartEvent => item.type === 'event',
|
||||
) as IChartEvent[];
|
||||
const events = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length !== 2) {
|
||||
if (events.length !== 2) {
|
||||
throw new Error('events must be an array of two events');
|
||||
}
|
||||
|
||||
@@ -44,8 +41,8 @@ export class ConversionService {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
const eventA = eventSeries[0]!;
|
||||
const eventB = eventSeries[1]!;
|
||||
const eventA = events[0]!;
|
||||
const eventB = events[1]!;
|
||||
const whereA = Object.values(
|
||||
getEventFiltersWhereClause(eventA.filters),
|
||||
).join(' AND ');
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
import { onlyReportEvents } from './reports.service';
|
||||
|
||||
export class FunnelService {
|
||||
constructor(private client: typeof ch) {}
|
||||
@@ -179,7 +180,6 @@ export class FunnelService {
|
||||
startDate,
|
||||
endDate,
|
||||
series,
|
||||
events, // Backward compatibility - use series if available
|
||||
funnelWindow = 24,
|
||||
funnelGroup,
|
||||
breakdowns = [],
|
||||
@@ -189,12 +189,7 @@ export class FunnelService {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const rawSeries = (series ?? events ?? []) as IChartEventItem[];
|
||||
const eventSeries = rawSeries.filter(
|
||||
(item): item is IChartEventItem & { type: 'event' } =>
|
||||
item.type === 'event',
|
||||
) as IChartEvent[];
|
||||
const eventSeries = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length === 0) {
|
||||
throw new Error('events are required');
|
||||
|
||||
@@ -5,21 +5,25 @@ import {
|
||||
} from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventFilter,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
ICriteria,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import { db } from '../prisma-client';
|
||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
|
||||
|
||||
export const onlyReportEvents = (
|
||||
series: NonNullable<IServiceReport>['series'],
|
||||
) => {
|
||||
return series.filter((item) => item.type === 'event');
|
||||
};
|
||||
|
||||
export function transformFilter(
|
||||
filter: Partial<IChartEventFilter>,
|
||||
index: number,
|
||||
@@ -34,72 +38,39 @@ export function transformFilter(
|
||||
}
|
||||
|
||||
export function transformReportEventItem(
|
||||
item: Partial<IChartEventItem> | Partial<IChartEvent>,
|
||||
item: IChartEventItem,
|
||||
index: number,
|
||||
): IChartEventItem {
|
||||
// If item already has type field, it's the new format
|
||||
if (item && typeof item === 'object' && 'type' in item) {
|
||||
if (item.type === 'formula') {
|
||||
// Transform formula
|
||||
const formula = item as Partial<IChartFormula>;
|
||||
return {
|
||||
type: 'formula',
|
||||
id: formula.id ?? alphabetIds[index]!,
|
||||
formula: formula.formula || '',
|
||||
displayName: formula.displayName,
|
||||
};
|
||||
}
|
||||
// Transform event with type field
|
||||
const event = item as Partial<IChartEvent>;
|
||||
if (item.type === 'formula') {
|
||||
// Transform formula
|
||||
return {
|
||||
type: 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: (event.filters ?? []).map(transformFilter),
|
||||
id: event.id ?? alphabetIds[index]!,
|
||||
name: event.name || 'unknown_event',
|
||||
displayName: event.displayName,
|
||||
property: event.property,
|
||||
type: 'formula',
|
||||
id: item.id ?? alphabetIds[index]!,
|
||||
formula: item.formula || '',
|
||||
displayName: item.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
// Old format without type field - assume it's an event
|
||||
const event = item as Partial<IChartEvent>;
|
||||
// Transform event with type field
|
||||
return {
|
||||
type: 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: (event.filters ?? []).map(transformFilter),
|
||||
id: event.id ?? alphabetIds[index]!,
|
||||
name: event.name || 'unknown_event',
|
||||
displayName: event.displayName,
|
||||
property: event.property,
|
||||
segment: item.segment ?? 'event',
|
||||
filters: (item.filters ?? []).map(transformFilter),
|
||||
id: item.id ?? alphabetIds[index]!,
|
||||
name: item.name || 'unknown_event',
|
||||
displayName: item.displayName,
|
||||
property: item.property,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the old function for backward compatibility, but it now uses the new transformer
|
||||
export function transformReportEvent(
|
||||
event: Partial<IChartEvent>,
|
||||
index: number,
|
||||
): IChartEvent {
|
||||
const transformed = transformReportEventItem(event, index);
|
||||
if (transformed.type === 'event') {
|
||||
return transformed;
|
||||
}
|
||||
// This shouldn't happen for old code, but handle it gracefully
|
||||
throw new Error('transformReportEvent called on a formula');
|
||||
}
|
||||
|
||||
export function transformReport(
|
||||
report: DbReport & { layout?: ReportLayout | null },
|
||||
): IChartProps & { id: string; layout?: ReportLayout | null } {
|
||||
// Events can be either old format (IChartEvent[]) or new format (IChartEventItem[])
|
||||
const eventsData = report.events as unknown as Array<
|
||||
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||
>;
|
||||
|
||||
return {
|
||||
id: report.id,
|
||||
projectId: report.projectId,
|
||||
series: eventsData.map(transformReportEventItem),
|
||||
series:
|
||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
chartType: report.chartType,
|
||||
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getProfilesCached,
|
||||
getSelectPropertyKey,
|
||||
getSettingsForProject,
|
||||
onlyReportEvents,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
type IChartEvent,
|
||||
@@ -611,9 +612,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// });
|
||||
|
||||
// Get unique profile IDs
|
||||
console.log('profileIdsQuery', getSql());
|
||||
const profileIds = await chQuery<{ profile_id: string }>(getSql());
|
||||
console.log('profileIds', profileIds.length);
|
||||
if (profileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -663,10 +662,7 @@ export const chartRouter = createTRPCRouter({
|
||||
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
||||
const targetLevel = stepIndex + 1;
|
||||
|
||||
const eventSeries = series.filter(
|
||||
(item): item is typeof item & { type: 'event' } =>
|
||||
item.type === 'event',
|
||||
);
|
||||
const eventSeries = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length === 0) {
|
||||
throw new Error('At least one event series is required');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type UnionOmit<T, K extends keyof any> = T extends any
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
|
||||
import type {
|
||||
zChartBreakdown,
|
||||
zChartEvent,
|
||||
|
||||
Reference in New Issue
Block a user