wip
This commit is contained in:
@@ -103,7 +103,6 @@
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.476.0",
|
||||
"mathjs": "^12.3.2",
|
||||
"mitt": "^3.0.1",
|
||||
"nuqs": "^2.5.2",
|
||||
"prisma-error-enum": "^0.1.3",
|
||||
|
||||
@@ -17,10 +17,10 @@ export function ReportChartEmpty({
|
||||
}) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { events },
|
||||
report: { series },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (events.length === 0) {
|
||||
if (!series || series.length === 0) {
|
||||
return (
|
||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { List, Rows3, Search, X } from 'lucide-react';
|
||||
|
||||
interface ReportTableToolbarProps {
|
||||
grouped: boolean;
|
||||
onToggleGrouped: () => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onUnselectAll: () => void;
|
||||
}
|
||||
|
||||
export function ReportTableToolbar({
|
||||
grouped,
|
||||
onToggleGrouped,
|
||||
search,
|
||||
onSearchChange,
|
||||
onUnselectAll,
|
||||
}: ReportTableToolbarProps) {
|
||||
return (
|
||||
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
|
||||
<div className="relative flex-1 w-full md:max-w-sm">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleGrouped}
|
||||
icon={grouped ? Rows3 : List}
|
||||
>
|
||||
{grouped ? 'Grouped' : 'Flat'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
|
||||
Unselect All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { getPropertyLabel } from '@/translations/properties';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
|
||||
export type TableRow = {
|
||||
id: string;
|
||||
serieName: string;
|
||||
breakdownValues: string[];
|
||||
sum: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
dateValues: Record<string, number>; // date -> count
|
||||
originalSerie: IChartData['series'][0];
|
||||
// Group metadata for collapse functionality
|
||||
groupKey?: string; // Unique key for the group this row belongs to
|
||||
parentGroupKey?: string; // Key of parent group (for nested groups)
|
||||
isSummaryRow?: boolean; // True if this is a summary row for a collapsed group
|
||||
};
|
||||
|
||||
export type GroupedTableRow = TableRow & {
|
||||
// For grouped mode, indicates which breakdown levels should show empty cells
|
||||
breakdownDisplay: (string | null)[]; // null means show empty cell
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract unique dates from all series
|
||||
*/
|
||||
function getUniqueDates(series: IChartData['series']): string[] {
|
||||
const dateSet = new Set<string>();
|
||||
series.forEach((serie) => {
|
||||
serie.data.forEach((d) => {
|
||||
dateSet.add(d.date);
|
||||
});
|
||||
});
|
||||
return Array.from(dateSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakdown property names from series
|
||||
* Breakdown values are in names.slice(1), so we need to infer the property names
|
||||
* from the breakdowns array or from the series structure
|
||||
*/
|
||||
function getBreakdownPropertyNames(
|
||||
series: IChartData['series'],
|
||||
breakdowns: Array<{ name: string }>,
|
||||
): string[] {
|
||||
// If we have breakdowns from state, use those
|
||||
if (breakdowns.length > 0) {
|
||||
return breakdowns.map((b) => getPropertyLabel(b.name));
|
||||
}
|
||||
|
||||
// Otherwise, infer from series names
|
||||
// All series should have the same number of breakdown values
|
||||
if (series.length === 0) return [];
|
||||
const firstSerie = series[0];
|
||||
const breakdownCount = firstSerie.names.length - 1;
|
||||
return Array.from({ length: breakdownCount }, (_, i) => `Breakdown ${i + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform series into flat table rows
|
||||
*/
|
||||
export function createFlatRows(
|
||||
series: IChartData['series'],
|
||||
dates: string[],
|
||||
): TableRow[] {
|
||||
return series.map((serie) => {
|
||||
const dateValues: Record<string, number> = {};
|
||||
dates.forEach((date) => {
|
||||
const dataPoint = serie.data.find((d) => d.date === date);
|
||||
dateValues[date] = dataPoint?.count ?? 0;
|
||||
});
|
||||
|
||||
return {
|
||||
id: serie.id,
|
||||
serieName: serie.names[0] ?? '',
|
||||
breakdownValues: serie.names.slice(1),
|
||||
sum: serie.metrics.sum,
|
||||
average: serie.metrics.average,
|
||||
min: serie.metrics.min,
|
||||
max: serie.metrics.max,
|
||||
dateValues,
|
||||
originalSerie: serie,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform series into grouped table rows
|
||||
* Groups rows hierarchically by breakdown values
|
||||
*/
|
||||
export function createGroupedRows(
|
||||
series: IChartData['series'],
|
||||
dates: string[],
|
||||
): GroupedTableRow[] {
|
||||
const flatRows = createFlatRows(series, dates);
|
||||
|
||||
// Sort by sum descending
|
||||
flatRows.sort((a, b) => b.sum - a.sum);
|
||||
|
||||
// Group rows by breakdown values hierarchically
|
||||
const grouped: GroupedTableRow[] = [];
|
||||
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
|
||||
|
||||
if (breakdownCount === 0) {
|
||||
// No breakdowns, just return flat rows
|
||||
return flatRows.map((row) => ({
|
||||
...row,
|
||||
breakdownDisplay: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Group rows hierarchically by breakdown values
|
||||
// We need to group by parent breakdowns first, then by child breakdowns
|
||||
// This creates the nested structure shown in the user's example
|
||||
|
||||
// First, group by first breakdown value
|
||||
const groupsByFirstBreakdown = new Map<string, TableRow[]>();
|
||||
flatRows.forEach((row) => {
|
||||
const firstBreakdown = row.breakdownValues[0] ?? '';
|
||||
if (!groupsByFirstBreakdown.has(firstBreakdown)) {
|
||||
groupsByFirstBreakdown.set(firstBreakdown, []);
|
||||
}
|
||||
groupsByFirstBreakdown.get(firstBreakdown)!.push(row);
|
||||
});
|
||||
|
||||
// Sort groups by sum of highest row in group
|
||||
const sortedGroups = Array.from(groupsByFirstBreakdown.entries()).sort(
|
||||
(a, b) => {
|
||||
const aMax = Math.max(...a[1].map((r) => r.sum));
|
||||
const bMax = Math.max(...b[1].map((r) => r.sum));
|
||||
return bMax - aMax;
|
||||
},
|
||||
);
|
||||
|
||||
// Process each group hierarchically
|
||||
sortedGroups.forEach(([firstBreakdownValue, groupRows]) => {
|
||||
// Within each first-breakdown group, sort by sum
|
||||
groupRows.sort((a, b) => b.sum - a.sum);
|
||||
|
||||
// Generate group key for this first-breakdown group
|
||||
const groupKey = firstBreakdownValue;
|
||||
|
||||
// For each row in the group
|
||||
groupRows.forEach((row, index) => {
|
||||
const breakdownDisplay: (string | null)[] = [];
|
||||
|
||||
if (index === 0) {
|
||||
// First row shows all breakdown values
|
||||
breakdownDisplay.push(...row.breakdownValues);
|
||||
} else {
|
||||
// Subsequent rows: compare with first row in group
|
||||
const firstRow = groupRows[0]!;
|
||||
|
||||
for (let i = 0; i < row.breakdownValues.length; i++) {
|
||||
// Show empty if this breakdown matches the first row at this position
|
||||
if (i < firstRow.breakdownValues.length) {
|
||||
if (row.breakdownValues[i] === firstRow.breakdownValues[i]) {
|
||||
breakdownDisplay.push(null);
|
||||
} else {
|
||||
breakdownDisplay.push(row.breakdownValues[i]!);
|
||||
}
|
||||
} else {
|
||||
breakdownDisplay.push(row.breakdownValues[i]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
grouped.push({
|
||||
...row,
|
||||
breakdownDisplay,
|
||||
groupKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a summary row for a collapsed group
|
||||
*/
|
||||
export function createSummaryRow(
|
||||
groupRows: TableRow[],
|
||||
groupKey: string,
|
||||
breakdownCount: number,
|
||||
): GroupedTableRow {
|
||||
// Aggregate metrics from all rows in the group
|
||||
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
||||
const totalAverage =
|
||||
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
|
||||
const totalMin = Math.min(...groupRows.map((row) => row.min));
|
||||
const totalMax = Math.max(...groupRows.map((row) => row.max));
|
||||
|
||||
// Aggregate date values
|
||||
const dateValues: Record<string, number> = {};
|
||||
const allDates = new Set<string>();
|
||||
groupRows.forEach((row) => {
|
||||
Object.keys(row.dateValues).forEach((date) => {
|
||||
allDates.add(date);
|
||||
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
|
||||
});
|
||||
});
|
||||
|
||||
// Get breakdown values from first row
|
||||
const firstRow = groupRows[0]!;
|
||||
const breakdownDisplay: (string | null)[] = [];
|
||||
breakdownDisplay.push(firstRow.breakdownValues[0] ?? null);
|
||||
// Fill remaining breakdowns with null (empty)
|
||||
for (let i = 1; i < breakdownCount; i++) {
|
||||
breakdownDisplay.push(null);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `summary-${groupKey}`,
|
||||
serieName: firstRow.serieName,
|
||||
breakdownValues: firstRow.breakdownValues,
|
||||
sum: totalSum,
|
||||
average: totalAverage,
|
||||
min: totalMin,
|
||||
max: totalMax,
|
||||
dateValues,
|
||||
originalSerie: firstRow.originalSerie,
|
||||
groupKey,
|
||||
isSummaryRow: true,
|
||||
breakdownDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder breakdowns by number of unique values (fewest first)
|
||||
*/
|
||||
function reorderBreakdownsByUniqueCount(
|
||||
series: IChartData['series'],
|
||||
breakdownPropertyNames: string[],
|
||||
): {
|
||||
reorderedNames: string[];
|
||||
reorderMap: number[]; // Maps new index -> old index
|
||||
reverseMap: number[]; // Maps old index -> new index
|
||||
} {
|
||||
if (breakdownPropertyNames.length === 0 || series.length === 0) {
|
||||
return {
|
||||
reorderedNames: breakdownPropertyNames,
|
||||
reorderMap: [],
|
||||
reverseMap: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Count unique values for each breakdown index
|
||||
const uniqueCounts = breakdownPropertyNames.map((_, index) => {
|
||||
const uniqueValues = new Set<string>();
|
||||
series.forEach((serie) => {
|
||||
const value = serie.names[index + 1]; // +1 because names[0] is serie name
|
||||
if (value) {
|
||||
uniqueValues.add(value);
|
||||
}
|
||||
});
|
||||
return { index, count: uniqueValues.size };
|
||||
});
|
||||
|
||||
// Sort by count (ascending - fewest first)
|
||||
uniqueCounts.sort((a, b) => a.count - b.count);
|
||||
|
||||
// Create reordered names and mapping
|
||||
const reorderedNames = uniqueCounts.map(
|
||||
(item) => breakdownPropertyNames[item.index]!,
|
||||
);
|
||||
const reorderMap = uniqueCounts.map((item) => item.index); // new index -> old index
|
||||
const reverseMap = new Array(breakdownPropertyNames.length);
|
||||
reorderMap.forEach((oldIndex, newIndex) => {
|
||||
reverseMap[oldIndex] = newIndex;
|
||||
});
|
||||
|
||||
return { reorderedNames, reorderMap, reverseMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform chart data into table-ready format
|
||||
*/
|
||||
export function transformToTableData(
|
||||
data: IChartData,
|
||||
breakdowns: Array<{ name: string }>,
|
||||
grouped: boolean,
|
||||
): {
|
||||
rows: TableRow[] | GroupedTableRow[];
|
||||
dates: string[];
|
||||
breakdownPropertyNames: string[];
|
||||
} {
|
||||
const dates = getUniqueDates(data.series);
|
||||
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
|
||||
data.series,
|
||||
breakdowns,
|
||||
);
|
||||
|
||||
// Reorder breakdowns by unique count (fewest first)
|
||||
const { reorderedNames: breakdownPropertyNames, reorderMap } =
|
||||
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
|
||||
|
||||
// Reorder breakdown values in series before creating rows
|
||||
const reorderedSeries = data.series.map((serie) => {
|
||||
const reorderedNames = [
|
||||
serie.names[0], // Keep serie name first
|
||||
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
|
||||
];
|
||||
return {
|
||||
...serie,
|
||||
names: reorderedNames,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = grouped
|
||||
? createGroupedRows(reorderedSeries, dates)
|
||||
: createFlatRows(reorderedSeries, dates);
|
||||
|
||||
// Sort flat rows by sum descending
|
||||
if (!grouped) {
|
||||
(rows as TableRow[]).sort((a, b) => b.sum - a.sum);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
dates,
|
||||
breakdownPropertyNames,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,14 +144,16 @@ export function Summary({ data }: Props) {
|
||||
title="Flow"
|
||||
value={
|
||||
<div className="row flex-wrap gap-1">
|
||||
{report.events.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{report.series
|
||||
.filter((item) => item.type === 'event')
|
||||
.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
funnelWindow,
|
||||
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
|
||||
} = useReportChartContext();
|
||||
|
||||
const input: IChartInput = {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
@@ -44,7 +44,7 @@ export function ReportFunnelChart() {
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.funnel.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.events.length > 0,
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
@@ -5,17 +11,12 @@ import { pushModal } from '@/modals';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UsersIcon, BookmarkIcon } from 'lucide-react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
@@ -51,14 +52,20 @@ export function Chart({ data }: Props) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
events,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
} = useReportChartContext();
|
||||
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null);
|
||||
const [clickPosition, setClickPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [clickedData, setClickedData] = useState<{
|
||||
date: string;
|
||||
serieId?: string;
|
||||
} | null>(null);
|
||||
const dataLength = data.series[0]?.data?.length || 0;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
@@ -144,9 +151,19 @@ export function Chart({ data }: Props) {
|
||||
const payload = e.activePayload[0].payload;
|
||||
const activeCoordinate = e.activeCoordinate;
|
||||
if (payload.date) {
|
||||
// Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
|
||||
const validPayload = e.activePayload.find(
|
||||
(p: any) =>
|
||||
p.dataKey &&
|
||||
p.dataKey !== 'calcStrokeDasharray' &&
|
||||
typeof p.dataKey === 'string' &&
|
||||
p.dataKey.includes(':count'),
|
||||
);
|
||||
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
|
||||
|
||||
setClickedData({
|
||||
date: payload.date,
|
||||
serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''),
|
||||
serieId,
|
||||
});
|
||||
setClickPosition({
|
||||
x: activeCoordinate?.x ?? 0,
|
||||
@@ -157,36 +174,39 @@ export function Chart({ data }: Props) {
|
||||
}, []);
|
||||
|
||||
const handleViewUsers = useCallback(() => {
|
||||
if (!clickedData || !projectId || !startDate || !endDate) return;
|
||||
|
||||
// Find the event for the clicked serie
|
||||
const serie = series.find((s) => s.id === clickedData.serieId);
|
||||
const event = events.find((e) => {
|
||||
const normalized = 'type' in e ? e : { ...e, type: 'event' as const };
|
||||
if (normalized.type === 'event') {
|
||||
return serie?.event.id === normalized.id || serie?.event.name === normalized.name;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!clickedData || !projectId) return;
|
||||
|
||||
if (event) {
|
||||
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
|
||||
if (normalized.type === 'event') {
|
||||
pushModal('ViewChartUsers', {
|
||||
projectId,
|
||||
event: normalized,
|
||||
date: clickedData.date,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
filters: normalized.filters || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Pass the chart data (which we already have) and the report config
|
||||
pushModal('ViewChartUsers', {
|
||||
chartData: data,
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
},
|
||||
date: clickedData.date,
|
||||
});
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
}, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]);
|
||||
}, [
|
||||
clickedData,
|
||||
projectId,
|
||||
data,
|
||||
reportSeries,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
]);
|
||||
|
||||
const handleAddReference = useCallback(() => {
|
||||
if (!clickedData) return;
|
||||
|
||||
@@ -12,7 +12,7 @@ import CohortTable from './table';
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
startDate,
|
||||
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||
const eventSeries = series.filter((item) => item.type === 'event');
|
||||
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
||||
const isEnabled =
|
||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -41,7 +41,7 @@ const initialState: InitialState = {
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
series: [],
|
||||
range: '30d',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
@@ -88,10 +88,10 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.name = action.payload;
|
||||
},
|
||||
// Events and Formulas
|
||||
// Series (Events and Formulas)
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
state.series.push({
|
||||
id: shortId(),
|
||||
type: 'event',
|
||||
...action.payload,
|
||||
@@ -102,7 +102,7 @@ export const reportSlice = createSlice({
|
||||
action: PayloadAction<Omit<IChartFormula, 'id'>>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
state.series.push({
|
||||
id: shortId(),
|
||||
...action.payload,
|
||||
} as IChartEventItem);
|
||||
@@ -113,16 +113,16 @@ export const reportSlice = createSlice({
|
||||
) => {
|
||||
state.dirty = true;
|
||||
if (action.payload.type === 'event') {
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
} else {
|
||||
state.events.push({
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
@@ -135,7 +135,7 @@ export const reportSlice = createSlice({
|
||||
}>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.filter(
|
||||
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;
|
||||
@@ -145,7 +145,7 @@ export const reportSlice = createSlice({
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.map((event) => {
|
||||
state.series = state.series.map((event) => {
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
if (eventId === action.payload.id) {
|
||||
return action.payload;
|
||||
@@ -293,9 +293,9 @@ export const reportSlice = createSlice({
|
||||
) {
|
||||
state.dirty = true;
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const [movedEvent] = state.events.splice(fromIndex, 1);
|
||||
const [movedEvent] = state.series.splice(fromIndex, 1);
|
||||
if (movedEvent) {
|
||||
state.events.splice(toIndex, 0, movedEvent);
|
||||
state.series.splice(toIndex, 0, movedEvent);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -266,60 +266,60 @@ export function ReportEvents() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
<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)
|
||||
? {
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: normalized.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...normalized,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
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'
|
||||
}
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(normalized as IChartEventItem & { type: 'event' }),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(normalized)} />
|
||||
</>
|
||||
)}
|
||||
@@ -328,38 +328,38 @@ 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(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportFormula() {
|
||||
const formula = useSelector((state) => state.report.formula);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Formula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputEnter
|
||||
placeholder="eg: A/B"
|
||||
value={formula ?? ''}
|
||||
onChangeValue={(value) => {
|
||||
dispatch(changeFormula(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
413
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
413
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addEvent,
|
||||
addFormula,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
function SortableSeries({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: eventId ?? '' });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportSeries() {
|
||||
const selectedSeries = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedSeries.findIndex((e) => e.id === active.id);
|
||||
const newIndex = selectedSeries.findIndex((e) => e.id === over.id);
|
||||
|
||||
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: 'type' in event ? event.id : (event as IChartEvent).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
const normalized =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedSeries.map((e) => ({
|
||||
id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '',
|
||||
}))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedSeries.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Name: Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).filters[0]?.value ?? [])
|
||||
: (
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(event as IChartEventItem & { type: 'event' }).name
|
||||
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={
|
||||
(event as IChartEventItem & { type: 'event' })
|
||||
.displayName
|
||||
}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addFormula({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
||||
import { useSelector } from '@/redux';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
import { ReportFormula } from './ReportFormula';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ReportSidebar() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
<ReportSeries />
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion';
|
||||
import { RefreshCcwIcon } from 'lucide-react';
|
||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
||||
import { Badge } from './badge';
|
||||
import { Input } from './input';
|
||||
import { Input, type InputProps } from './input';
|
||||
|
||||
export function InputEnter({
|
||||
value,
|
||||
@@ -13,7 +13,7 @@ export function InputEnter({
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChangeValue: (value: string) => void;
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
} & InputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +33,6 @@ export function InputEnter({
|
||||
onChangeValue(internalValue);
|
||||
}
|
||||
}}
|
||||
size="default"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -1,62 +1,197 @@
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
interface ViewChartUsersProps {
|
||||
projectId: string;
|
||||
event: IChartEvent;
|
||||
chartData: IChartData;
|
||||
report: IChartInput;
|
||||
date: string;
|
||||
breakdowns?: Array<{ id?: string; name: string }>;
|
||||
interval: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
filters?: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
operator: string;
|
||||
value: Array<string | number | boolean | null>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ViewChartUsers({
|
||||
projectId,
|
||||
event,
|
||||
chartData,
|
||||
report,
|
||||
date,
|
||||
breakdowns = [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
filters = [],
|
||||
}: ViewChartUsersProps) {
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.chart.getProfiles.queryOptions({
|
||||
projectId,
|
||||
event,
|
||||
date,
|
||||
breakdowns,
|
||||
interval: interval as any,
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
}),
|
||||
|
||||
// Group series by base event/formula (ignoring breakdowns)
|
||||
const baseSeries = useMemo(() => {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
baseName: string;
|
||||
baseEventId: string;
|
||||
reportSerie: IChartInput['series'][0] | undefined;
|
||||
breakdownSeries: Array<{
|
||||
serie: IChartData['series'][0];
|
||||
breakdowns: Record<string, string> | undefined;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
chartData.series.forEach((serie) => {
|
||||
const baseEventId = serie.event.id || '';
|
||||
const baseName = serie.names[0] || 'Unnamed Serie';
|
||||
|
||||
if (!grouped.has(baseEventId)) {
|
||||
const reportSerie = report.series.find((ss) => ss.id === baseEventId);
|
||||
grouped.set(baseEventId, {
|
||||
baseName,
|
||||
baseEventId,
|
||||
reportSerie,
|
||||
breakdownSeries: [],
|
||||
});
|
||||
}
|
||||
|
||||
const group = grouped.get(baseEventId);
|
||||
if (!group) return;
|
||||
// Extract breakdowns from serie.event.breakdowns (set in format.ts)
|
||||
const breakdowns = (serie.event as any).breakdowns;
|
||||
|
||||
group.breakdownSeries.push({
|
||||
serie,
|
||||
breakdowns,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}, [chartData.series, report.series, report.breakdowns]);
|
||||
|
||||
const [selectedBaseSerieId, setSelectedBaseSerieId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const selectedBaseSerie = useMemo(
|
||||
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
|
||||
[baseSeries, selectedBaseSerieId],
|
||||
);
|
||||
|
||||
const profiles = query.data ?? [];
|
||||
const selectedBreakdown = useMemo(() => {
|
||||
if (
|
||||
!selectedBaseSerie ||
|
||||
selectedBreakdownIndex === null ||
|
||||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
|
||||
}, [selectedBaseSerie, selectedBreakdownIndex]);
|
||||
|
||||
// Reset breakdown selection when base serie changes
|
||||
const handleBaseSerieChange = (value: string) => {
|
||||
setSelectedBaseSerieId(value);
|
||||
setSelectedBreakdownIndex(null);
|
||||
};
|
||||
|
||||
const selectedSerie = selectedBreakdown || selectedBaseSerie;
|
||||
|
||||
const profilesQuery = useQuery(
|
||||
trpc.chart.getProfiles.queryOptions(
|
||||
{
|
||||
projectId: report.projectId,
|
||||
date: date,
|
||||
series:
|
||||
selectedSerie &&
|
||||
selectedBaseSerie?.reportSerie &&
|
||||
selectedBaseSerie.reportSerie.type === 'event'
|
||||
? [selectedBaseSerie.reportSerie]
|
||||
: [],
|
||||
breakdowns: selectedBreakdown?.breakdowns,
|
||||
interval: report.interval,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!selectedSerie &&
|
||||
!!selectedBaseSerie?.reportSerie &&
|
||||
selectedBaseSerie.reportSerie.type === 'event',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const profiles = profilesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader
|
||||
title="View Users"
|
||||
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`}
|
||||
/>
|
||||
<ModalHeader title="View Users" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Users who performed actions on {new Date(date).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{query.isLoading ? (
|
||||
{baseSeries.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Serie:</label>
|
||||
<Select
|
||||
value={selectedBaseSerieId || ''}
|
||||
onValueChange={handleBaseSerieChange}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select Serie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{baseSeries.map((baseSerie) => (
|
||||
<SelectItem
|
||||
key={baseSerie.baseEventId}
|
||||
value={baseSerie.baseEventId}
|
||||
>
|
||||
{baseSerie.baseName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedBaseSerie &&
|
||||
selectedBaseSerie.breakdownSeries.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Breakdown:</label>
|
||||
<Select
|
||||
value={selectedBreakdownIndex?.toString() || ''}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBreakdownIndex(
|
||||
value ? Number.parseInt(value, 10) : null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="All Breakdowns" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Breakdowns</SelectItem>
|
||||
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => (
|
||||
<SelectItem
|
||||
key={bdSerie.serie.id}
|
||||
value={idx.toString()}
|
||||
>
|
||||
{bdSerie.serie.names.slice(1).join(' > ') ||
|
||||
'No Breakdown'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{profilesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">Loading users...</div>
|
||||
</div>
|
||||
@@ -109,4 +244,3 @@ export default function ViewChartUsers({
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user