feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Wed Nov 26 12:32:40 2025 +0100 wip commit8cd3b89fa3Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:33:58 2025 +0100 funnel commit95af86dc44Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:23:25 2025 +0100 wip commit727a218e6bAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:26 2025 +0100 conversion wip commit958ba535d6Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:20 2025 +0100 wip commit3bbeb927ccAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 09:18:48 2025 +0100 wip commitd99335e2f4Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 18:08:10 2025 +0100 wip commit1fa61b1ae9Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 15:50:28 2025 +0100 ts commit548747d826Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:17:01 2025 +0100 fix typecheck events -> series commit7b18544085Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:06:46 2025 +0100 fix report table commit57697a5a39Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Sat Nov 22 00:05:13 2025 +0100 wip commit06fb6c4f3cAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Fri Nov 21 11:21:17 2025 +0100 wip commitdd71fd4e11Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Thu Nov 20 13:56:58 2025 +0100 formulas
This commit is contained in:
398
apps/start/src/modals/view-chart-users.tsx
Normal file
398
apps/start/src/modals/view-chart-users.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalHeader } from './Modal/Container';
|
||||
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
|
||||
|
||||
const ProfileItem = ({ profile }: { profile: any }) => {
|
||||
return (
|
||||
<ProjectLink
|
||||
preload={false}
|
||||
href={`/profiles/${profile.id}`}
|
||||
title={getProfileName(profile, false)}
|
||||
className="col gap-2 rounded-lg border p-2 bg-card"
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
popModal();
|
||||
}}
|
||||
>
|
||||
<div className="row gap-2 items-center">
|
||||
<ProfileAvatar {...profile} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{getProfileName(profile)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row gap-4 text-sm overflow-hidden">
|
||||
{profile.properties.country && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={profile.properties.country} />
|
||||
<span>
|
||||
{profile.properties.country}
|
||||
{profile.properties.city && ` / ${profile.properties.city}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{profile.properties.os && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={profile.properties.os} />
|
||||
<span>{profile.properties.os}</span>
|
||||
</div>
|
||||
)}
|
||||
{profile.properties.browser && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={profile.properties.browser} />
|
||||
<span>{profile.properties.browser}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLink>
|
||||
);
|
||||
};
|
||||
// Shared profile list component
|
||||
function ProfileList({ profiles }: { profiles: any[] }) {
|
||||
const ITEM_HEIGHT = 74;
|
||||
const CONTAINER_PADDING = 20;
|
||||
const ITEM_GAP = 5;
|
||||
const { scrollAreaRef } = useScrollableModal();
|
||||
const [isScrollReady, setIsScrollReady] = useState(false);
|
||||
|
||||
// Check if scroll container is ready
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
setIsScrollReady(true);
|
||||
} else {
|
||||
setIsScrollReady(false);
|
||||
}
|
||||
}, [scrollAreaRef]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: profiles.length,
|
||||
getScrollElement: () => scrollAreaRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
|
||||
overscan: 5,
|
||||
paddingStart: CONTAINER_PADDING,
|
||||
paddingEnd: CONTAINER_PADDING,
|
||||
});
|
||||
|
||||
// Re-measure when scroll container becomes available or profiles change
|
||||
useEffect(() => {
|
||||
if (isScrollReady && scrollAreaRef.current) {
|
||||
// Small delay to ensure DOM is ready
|
||||
const timeoutId = setTimeout(() => {
|
||||
virtualizer.measure();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isScrollReady, profiles.length, virtualizer]);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">No users found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const profile = profiles[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
|
||||
}}
|
||||
>
|
||||
<ProfileItem profile={profile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chart-specific props and component
|
||||
interface ChartUsersViewProps {
|
||||
chartData: IChartData;
|
||||
report: IChartInput;
|
||||
date: string;
|
||||
}
|
||||
|
||||
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||
const trpc = useTRPC();
|
||||
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
|
||||
report.series[0]?.id || null,
|
||||
);
|
||||
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedReportSerie = useMemo(
|
||||
() => report.series.find((s) => s.id === selectedSerieId),
|
||||
[report.series, selectedSerieId],
|
||||
);
|
||||
|
||||
// Get all chart series that match the selected report serie
|
||||
const matchingChartSeries = useMemo(() => {
|
||||
if (!selectedSerieId || !chartData) return [];
|
||||
return chartData.series.filter((s) => s.event.id === selectedSerieId);
|
||||
}, [chartData?.series, selectedSerieId]);
|
||||
|
||||
const selectedBreakdown = useMemo(() => {
|
||||
if (!selectedBreakdownId) return null;
|
||||
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
|
||||
}, [matchingChartSeries, selectedBreakdownId]);
|
||||
|
||||
// Reset breakdown selection when serie changes
|
||||
const handleSerieChange = (value: string) => {
|
||||
setSelectedSerieId(value);
|
||||
setSelectedBreakdownId(null);
|
||||
};
|
||||
|
||||
const profilesQuery = useQuery(
|
||||
trpc.chart.getProfiles.queryOptions(
|
||||
{
|
||||
projectId: report.projectId,
|
||||
date: date,
|
||||
series:
|
||||
selectedReportSerie && selectedReportSerie.type === 'event'
|
||||
? [selectedReportSerie]
|
||||
: [],
|
||||
breakdowns: selectedBreakdown?.event.breakdowns,
|
||||
interval: report.interval,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const profiles = profilesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<ScrollableModal
|
||||
header={
|
||||
<div>
|
||||
<ModalHeader
|
||||
title="View Users"
|
||||
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
|
||||
/>
|
||||
{report.series.length > 0 && (
|
||||
<div className="col md:row gap-2">
|
||||
<Select
|
||||
value={selectedSerieId || ''}
|
||||
onValueChange={handleSerieChange}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select Serie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{report.series.map((serie) => (
|
||||
<SelectItem key={serie.id} value={serie.id || ''}>
|
||||
{serie.type === 'event'
|
||||
? serie.displayName || serie.name
|
||||
: serie.displayName || 'Formula'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{matchingChartSeries.length > 1 && (
|
||||
<Select
|
||||
value={selectedBreakdownId || ''}
|
||||
onValueChange={(value) => setSelectedBreakdownId(value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select Breakdown" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchingChartSeries
|
||||
.sort((a, b) => b.metrics.sum - a.metrics.sum)
|
||||
.map((serie) => (
|
||||
<SelectItem key={serie.id} value={serie.id}>
|
||||
{Object.values(serie.event.breakdowns ?? {}).join(
|
||||
', ',
|
||||
)}
|
||||
<DropdownMenuShortcut className="ml-auto">
|
||||
({serie.data.find((d) => d.date === date)?.count})
|
||||
</DropdownMenuShortcut>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="col">
|
||||
{profilesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">Loading users...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileList profiles={profiles} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollableModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Funnel-specific props and component
|
||||
interface FunnelUsersViewProps {
|
||||
report: IChartInput;
|
||||
stepIndex: number;
|
||||
}
|
||||
|
||||
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||
const trpc = useTRPC();
|
||||
const [showDropoffs, setShowDropoffs] = useState(false);
|
||||
|
||||
const profilesQuery = useQuery(
|
||||
trpc.chart.getFunnelProfiles.queryOptions(
|
||||
{
|
||||
projectId: report.projectId,
|
||||
startDate: report.startDate!,
|
||||
endDate: report.endDate!,
|
||||
range: report.range,
|
||||
series: report.series,
|
||||
stepIndex: stepIndex,
|
||||
showDropoffs: showDropoffs,
|
||||
funnelWindow: report.funnelWindow,
|
||||
funnelGroup: report.funnelGroup,
|
||||
breakdowns: report.breakdowns,
|
||||
},
|
||||
{
|
||||
enabled: stepIndex !== undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const profiles = profilesQuery.data ?? [];
|
||||
const isLastStep = stepIndex === report.series.length - 1;
|
||||
|
||||
return (
|
||||
<ScrollableModal
|
||||
header={
|
||||
<div className="flex flex-col gap-2">
|
||||
<ModalHeader
|
||||
title="View Users"
|
||||
text={
|
||||
showDropoffs
|
||||
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
|
||||
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
|
||||
}
|
||||
/>
|
||||
{!isLastStep && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDropoffs(false)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
!showDropoffs
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||
)}
|
||||
>
|
||||
Completed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDropoffs(true)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
showDropoffs
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||
)}
|
||||
>
|
||||
Dropped Off
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{profilesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">Loading users...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileList profiles={profiles} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollableModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Union type for props
|
||||
type ViewChartUsersProps =
|
||||
| {
|
||||
type: 'chart';
|
||||
chartData: IChartData;
|
||||
report: IChartInput;
|
||||
date: string;
|
||||
}
|
||||
| {
|
||||
type: 'funnel';
|
||||
report: IChartInput;
|
||||
stepIndex: number;
|
||||
};
|
||||
|
||||
// Main component that routes to the appropriate view
|
||||
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
||||
if (props.type === 'funnel') {
|
||||
return (
|
||||
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartUsersView
|
||||
chartData={props.chartData}
|
||||
report={props.report}
|
||||
date={props.date}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user