wip
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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,
|
||||
@@ -9,119 +11,197 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
import { ModalHeader } from './Modal/Container';
|
||||
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
|
||||
|
||||
interface ViewChartUsersProps {
|
||||
const ProfileItem = ({ profile }: { profile: any }) => {
|
||||
console.log('ProfileItem', profile.id);
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ViewChartUsers({
|
||||
chartData,
|
||||
report,
|
||||
date,
|
||||
}: ViewChartUsersProps) {
|
||||
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||
const trpc = useTRPC();
|
||||
|
||||
// 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>(
|
||||
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
|
||||
report.series[0]?.id || null,
|
||||
);
|
||||
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const selectedBaseSerie = useMemo(
|
||||
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
|
||||
[baseSeries, selectedBaseSerieId],
|
||||
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 (
|
||||
!selectedBaseSerie ||
|
||||
selectedBreakdownIndex === null ||
|
||||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
|
||||
}, [selectedBaseSerie, selectedBreakdownIndex]);
|
||||
if (!selectedBreakdownId) return null;
|
||||
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
|
||||
}, [matchingChartSeries, selectedBreakdownId]);
|
||||
|
||||
// Reset breakdown selection when base serie changes
|
||||
const handleBaseSerieChange = (value: string) => {
|
||||
setSelectedBaseSerieId(value);
|
||||
setSelectedBreakdownIndex(null);
|
||||
// Reset breakdown selection when serie changes
|
||||
const handleSerieChange = (value: string) => {
|
||||
setSelectedSerieId(value);
|
||||
setSelectedBreakdownId(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]
|
||||
selectedReportSerie && selectedReportSerie.type === 'event'
|
||||
? [selectedReportSerie]
|
||||
: [],
|
||||
breakdowns: selectedBreakdown?.breakdowns,
|
||||
breakdowns: selectedBreakdown?.event.breakdowns,
|
||||
interval: report.interval,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!selectedSerie &&
|
||||
!!selectedBaseSerie?.reportSerie &&
|
||||
selectedBaseSerie.reportSerie.type === 'event',
|
||||
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -129,118 +209,191 @@ export default function ViewChartUsers({
|
||||
const profiles = profilesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<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">
|
||||
{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>
|
||||
<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={selectedBaseSerieId || ''}
|
||||
onValueChange={handleBaseSerieChange}
|
||||
value={selectedSerieId || ''}
|
||||
onValueChange={handleSerieChange}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select Serie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{baseSeries.map((baseSerie) => (
|
||||
<SelectItem
|
||||
key={baseSerie.baseEventId}
|
||||
value={baseSerie.baseEventId}
|
||||
>
|
||||
{baseSerie.baseName}
|
||||
{report.series.map((serie) => (
|
||||
<SelectItem key={serie.id} value={serie.id || ''}>
|
||||
{serie.type === 'event'
|
||||
? serie.displayName || serie.name
|
||||
: serie.displayName || 'Formula'}
|
||||
</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'}
|
||||
{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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">No users found</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
{profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{profile.avatar ? (
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt={profile.firstName || profile.email}
|
||||
className="size-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
|
||||
<UsersIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{profile.firstName || profile.lastName
|
||||
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
|
||||
: profile.email || 'Anonymous'}
|
||||
</div>
|
||||
{profile.email && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{profile.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileList profiles={profiles} />
|
||||
)}
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Close
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</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