feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode * fix: handle past_due and unpaid from polar * wip * wip * wip 1 * fix: improve types for chart/reports * wip share
This commit is contained in:
committed by
GitHub
parent
39251c8598
commit
ed1c57dbb8
@@ -5,6 +5,7 @@ import {
|
||||
ChartColumnIncreasingIcon,
|
||||
ConeIcon,
|
||||
GaugeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
LineChartIcon,
|
||||
type LucideIcon,
|
||||
@@ -58,6 +59,7 @@ export function ReportChartType({
|
||||
retention: UsersIcon,
|
||||
map: Globe2Icon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
255
apps/start/src/components/report/report-item.tsx
Normal file
255
apps/start/src/components/report/report-item.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
export function ReportItemSkeleton() {
|
||||
return (
|
||||
<div className="card h-full flex flex-col animate-pulse">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-muted/50 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportItem({
|
||||
report,
|
||||
organizationId,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: {
|
||||
report: any;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
onDelete: (reportId: string) => void;
|
||||
onDuplicate: (reportId: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
||||
<div
|
||||
className="flex-1 cursor-pointer -m-4 p-4"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey) {
|
||||
window.open(
|
||||
`/${organizationId}/${projectId}/reports/${report.id}`,
|
||||
'_blank',
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.navigate({
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
router.navigate({
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="opacity-30 hover:opacity-100"
|
||||
>
|
||||
<circle cx="4" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="8" r="1.5" />
|
||||
<circle cx="4" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="4" r="1.5" />
|
||||
<circle cx="12" cy="8" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate(report.id);
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(report.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportItemReadOnly({
|
||||
report,
|
||||
shareId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
}: {
|
||||
report: any;
|
||||
shareId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
}) {
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
}}
|
||||
shareId={shareId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
|
||||
|
||||
import { shortId } from '@openpanel/common';
|
||||
import {
|
||||
@@ -12,18 +11,19 @@ import {
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
IReport,
|
||||
IReportOptions,
|
||||
UnionOmit,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type InitialState = IChartProps & {
|
||||
type InitialState = IReport & {
|
||||
id?: string;
|
||||
dirty: boolean;
|
||||
ready: boolean;
|
||||
startDate: string | null;
|
||||
@@ -34,7 +34,6 @@ type InitialState = IChartProps & {
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
// TODO: remove this
|
||||
projectId: '',
|
||||
name: '',
|
||||
chartType: 'linear',
|
||||
@@ -50,9 +49,7 @@ const initialState: InitialState = {
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
limit: 500,
|
||||
criteria: 'on_or_after',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: undefined,
|
||||
options: undefined,
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -74,7 +71,7 @@ export const reportSlice = createSlice({
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setReport(state, action: PayloadAction<IChartProps>) {
|
||||
setReport(state, action: PayloadAction<IReport>) {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
@@ -187,6 +184,16 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.chartType = action.payload;
|
||||
|
||||
// Initialize sankey options if switching to sankey
|
||||
if (action.payload === 'sankey' && !state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'minute'
|
||||
@@ -254,7 +261,14 @@ export const reportSlice = createSlice({
|
||||
|
||||
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||
state.dirty = true;
|
||||
state.criteria = action.payload;
|
||||
if (!state.options || state.options.type !== 'retention') {
|
||||
state.options = {
|
||||
type: 'retention',
|
||||
criteria: action.payload,
|
||||
};
|
||||
} else {
|
||||
state.options.criteria = action.payload;
|
||||
}
|
||||
},
|
||||
|
||||
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||
@@ -264,12 +278,88 @@ export const reportSlice = createSlice({
|
||||
|
||||
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
||||
state.dirty = true;
|
||||
state.funnelGroup = action.payload || undefined;
|
||||
if (!state.options || state.options.type !== 'funnel') {
|
||||
state.options = {
|
||||
type: 'funnel',
|
||||
funnelGroup: action.payload,
|
||||
funnelWindow: undefined,
|
||||
};
|
||||
} else {
|
||||
state.options.funnelGroup = action.payload;
|
||||
}
|
||||
},
|
||||
|
||||
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
||||
state.dirty = true;
|
||||
state.funnelWindow = action.payload || undefined;
|
||||
if (!state.options || state.options.type !== 'funnel') {
|
||||
state.options = {
|
||||
type: 'funnel',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: action.payload,
|
||||
};
|
||||
} else {
|
||||
state.options.funnelWindow = action.payload;
|
||||
}
|
||||
},
|
||||
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
|
||||
state.dirty = true;
|
||||
state.options = action.payload || undefined;
|
||||
},
|
||||
changeSankeyMode(
|
||||
state,
|
||||
action: PayloadAction<'between' | 'after' | 'before'>,
|
||||
) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: action.payload,
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.mode = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeySteps(state, action: PayloadAction<number>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: action.payload,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.steps = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyExclude(state, action: PayloadAction<string[]>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.exclude = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyInclude(state, action: PayloadAction<string[] | undefined>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
include: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.include = action.payload;
|
||||
}
|
||||
},
|
||||
reorderEvents(
|
||||
state,
|
||||
@@ -311,6 +401,11 @@ export const {
|
||||
changeUnit,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changeOptions,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
reorderEvents,
|
||||
} = reportSlice.actions;
|
||||
|
||||
|
||||
@@ -23,15 +23,13 @@ import {
|
||||
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 { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
@@ -39,27 +37,21 @@ import {
|
||||
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';
|
||||
import {
|
||||
ReportSeriesItem,
|
||||
type ReportSeriesItemProps,
|
||||
} from './ReportSeriesItem';
|
||||
|
||||
function SortableSeries({
|
||||
function SortableReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: eventId ?? '' });
|
||||
@@ -69,85 +61,26 @@ function SortableSeries({
|
||||
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 ref={setNodeRef} style={style} {...attributes}>
|
||||
<ReportSeriesItem
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
renderDragHandle={(index) => (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,12 +94,23 @@ export function ReportSeries() {
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention', 'sankey'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
const isSankey = chartType === 'sankey';
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const isSankeyEventLimitReached =
|
||||
isSankey &&
|
||||
options &&
|
||||
((options.type === 'sankey' &&
|
||||
options.mode === 'between' &&
|
||||
selectedSeries.length >= 2) ||
|
||||
(options.type === 'sankey' &&
|
||||
options.mode !== 'between' &&
|
||||
selectedSeries.length >= 1));
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
@@ -218,7 +162,8 @@ export function ReportSeries() {
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -239,7 +184,7 @@ export function ReportSeries() {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
<SortableReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
@@ -348,13 +293,14 @@ export function ReportSeries() {
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
</SortableReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
className="flex-1"
|
||||
disabled={isAddEventDisabled || isSankeyEventLimitReached}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
@@ -386,13 +332,13 @@ export function ReportSeries() {
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
className="flex-1"
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
className="flex-1 justify-start text-left px-4"
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
@@ -402,9 +348,9 @@ export function ReportSeries() {
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="px-4"
|
||||
>
|
||||
Add Formula
|
||||
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
114
apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
Normal file
114
apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
export interface ReportSeriesItemProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
renderDragHandle?: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
renderDragHandle,
|
||||
...props
|
||||
}: ReportSeriesItemProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 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 {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
{renderDragHandle ? (
|
||||
renderDragHandle(index)
|
||||
) : (
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
changeCriteria,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changePrevious,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeUnit,
|
||||
} from '../reportSlice';
|
||||
|
||||
export function ReportSettings() {
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const criteria = useSelector((state) => state.report.criteria);
|
||||
const unit = useSelector((state) => state.report.unit);
|
||||
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
||||
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
|
||||
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({ projectId });
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const fields = [];
|
||||
|
||||
if (chartType !== 'retention') {
|
||||
if (chartType !== 'retention' && chartType !== 'sankey') {
|
||||
fields.push('previous');
|
||||
}
|
||||
|
||||
@@ -40,6 +54,13 @@ export function ReportSettings() {
|
||||
fields.push('funnelWindow');
|
||||
}
|
||||
|
||||
if (chartType === 'sankey') {
|
||||
fields.push('sankeyMode');
|
||||
fields.push('sankeySteps');
|
||||
fields.push('sankeyExclude');
|
||||
fields.push('sankeyInclude');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
@@ -50,7 +71,7 @@ export function ReportSettings() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Settings</h3>
|
||||
<div className="col rounded-lg border bg-card p-4 gap-2">
|
||||
<div className="col rounded-lg border bg-card p-4 gap-4">
|
||||
{fields.includes('previous') && (
|
||||
<Label className="flex items-center justify-between mb-0">
|
||||
<span className="whitespace-nowrap">
|
||||
@@ -64,7 +85,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('criteria') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Criteria</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Criteria
|
||||
</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select criteria"
|
||||
@@ -85,7 +108,7 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('unit') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Unit</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Unit"
|
||||
@@ -108,7 +131,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('funnelGroup') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Funnel Group</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Funnel Group
|
||||
</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Default: Session"
|
||||
@@ -133,7 +158,9 @@ export function ReportSettings() {
|
||||
)}
|
||||
{fields.includes('funnelWindow') && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Funnel Window</span>
|
||||
<Label className="whitespace-nowrap font-medium mb-0">
|
||||
Funnel Window
|
||||
</Label>
|
||||
<InputEnter
|
||||
type="number"
|
||||
value={funnelWindow ? String(funnelWindow) : ''}
|
||||
@@ -149,6 +176,89 @@ export function ReportSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select mode"
|
||||
value={options?.mode || 'after'}
|
||||
onChange={(val) => {
|
||||
dispatch(
|
||||
changeSankeyMode(val as 'between' | 'after' | 'before'),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
label: 'After',
|
||||
value: 'after',
|
||||
},
|
||||
{
|
||||
label: 'Before',
|
||||
value: 'before',
|
||||
},
|
||||
{
|
||||
label: 'Between',
|
||||
value: 'between',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeySteps') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
|
||||
<InputEnter
|
||||
type="number"
|
||||
value={options?.steps ? String(options.steps) : '5'}
|
||||
placeholder="Default: 5"
|
||||
onChangeValue={(value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 2 || parsed > 10) {
|
||||
dispatch(changeSankeySteps(5));
|
||||
} else {
|
||||
dispatch(changeSankeySteps(parsed));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyExclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col">
|
||||
<Label className="whitespace-nowrap font-medium">
|
||||
Exclude Events
|
||||
</Label>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.exclude || []}
|
||||
onChange={(value) => {
|
||||
dispatch(changeSankeyExclude(value));
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Select events to exclude"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyInclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col">
|
||||
<Label className="whitespace-nowrap font-medium">
|
||||
Include events
|
||||
</Label>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.include || []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeSankeyInclude(value.length > 0 ? value : undefined),
|
||||
);
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Leave empty to include all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
import { ReportFixedEvents } from './report-fixed-events';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
const { chartType, options } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
|
||||
const showFixedEvents = chartType === 'sankey';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportSeries />
|
||||
{showFixedEvents ? (
|
||||
<ReportFixedEvents
|
||||
numberOfEvents={
|
||||
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ReportSeries />
|
||||
)}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
|
||||
223
apps/start/src/components/report/sidebar/report-fixed-events.tsx
Normal file
223
apps/start/src/components/report/sidebar/report-fixed-events.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
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 { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { ReportSeriesItem } from './ReportSeriesItem';
|
||||
|
||||
export function ReportFixedEvents({
|
||||
numberOfEvents,
|
||||
}: {
|
||||
numberOfEvents: number;
|
||||
}) {
|
||||
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', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
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' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: numberOfEvents }, (_, index) => ({
|
||||
slotId: `fixed-event-slot-${index}`,
|
||||
index,
|
||||
})).map(({ slotId, index }) => {
|
||||
const event = selectedSeries[index];
|
||||
|
||||
// If no event exists at this index, render an empty slot
|
||||
if (!event) {
|
||||
return (
|
||||
<div key={slotId} className="rounded-lg border bg-def-100">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={''}
|
||||
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: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFormula = event.type === 'formula';
|
||||
if (isFormula) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
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 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)} />
|
||||
</ReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user