feat: add sortable overview widgets
This commit is contained in:
@@ -4,7 +4,8 @@ import { cn } from '@/utils/cn';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -106,7 +107,9 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
// Calculate totals and check for revenue
|
||||
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
||||
useMemo(() => {
|
||||
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
|
||||
const maxSessions = Math.max(
|
||||
...filteredData.map((item) => item.sessions),
|
||||
);
|
||||
const totalRevenue = filteredData.reduce(
|
||||
(sum, item) => sum + (item.revenue ?? 0),
|
||||
0,
|
||||
@@ -152,7 +155,8 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
<div
|
||||
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
||||
style={{
|
||||
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
gridTemplateColumns:
|
||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
}}
|
||||
>
|
||||
<div className="text-left truncate">{columnName}</div>
|
||||
@@ -204,11 +208,14 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
<div
|
||||
className="relative grid h-full items-center px-4 border-b border-border"
|
||||
style={{
|
||||
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
gridTemplateColumns:
|
||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
}}
|
||||
>
|
||||
{/* Main content cell */}
|
||||
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
|
||||
<div className="min-w-0 truncate pr-2">
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
|
||||
{/* Revenue cell */}
|
||||
{hasRevenue && (
|
||||
@@ -261,4 +268,3 @@ export function OverviewListModal<T extends OverviewListItem>({
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
const data = query.data ?? [];
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return tableData.slice(0, 15);
|
||||
return tableData;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return tableData
|
||||
.filter((item) => item.name?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 15);
|
||||
return tableData.filter((item) =>
|
||||
item.name?.toLowerCase().includes(queryLower),
|
||||
);
|
||||
}, [tableData, searchQuery]);
|
||||
|
||||
const tabs = useMemo(
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
const data = query.data ?? [];
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = query.data?.slice(0, 15) ?? [];
|
||||
const data = query.data ?? [];
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function OverviewTopSources({
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
const data = query.data ?? [];
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
@@ -45,6 +46,42 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
name,
|
||||
isSorted,
|
||||
sortDirection,
|
||||
onClick,
|
||||
isRightAligned,
|
||||
}: {
|
||||
name: string;
|
||||
isSorted: boolean;
|
||||
sortDirection: 'asc' | 'desc' | null;
|
||||
onClick: () => void;
|
||||
isRightAligned?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'row items-center gap-1 hover:opacity-80 transition-opacity',
|
||||
isRightAligned && 'justify-end ml-auto',
|
||||
)}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{isSorted ? (
|
||||
sortDirection === 'desc' ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronUp className="size-3" />
|
||||
)
|
||||
) : (
|
||||
<ChevronDown className="size-3 opacity-30" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = WidgetTableProps<T> & {
|
||||
getColumnPercentage: (item: T) => number;
|
||||
};
|
||||
@@ -56,10 +93,113 @@ export const OverviewWidgetTable = <T,>({
|
||||
getColumnPercentage,
|
||||
className,
|
||||
}: Props<T>) => {
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Handle column header click for sorting
|
||||
const handleSort = (columnName: string) => {
|
||||
if (sortColumn === columnName) {
|
||||
// Cycle through: desc -> asc -> null
|
||||
if (sortDirection === 'desc') {
|
||||
setSortDirection('asc');
|
||||
} else if (sortDirection === 'asc') {
|
||||
setSortColumn(null);
|
||||
setSortDirection(null);
|
||||
}
|
||||
} else {
|
||||
// First click on a column = descending (highest to lowest)
|
||||
setSortColumn(columnName);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort data based on current sort state
|
||||
// Sort all available items, then limit display to top 15
|
||||
const sortedData = useMemo(() => {
|
||||
const allData = data ?? [];
|
||||
|
||||
if (!sortColumn || !sortDirection) {
|
||||
// When not sorting, return top 15 (maintain original behavior)
|
||||
return allData;
|
||||
}
|
||||
|
||||
const column = columns.find((col) => {
|
||||
if (typeof col.name === 'string') {
|
||||
return col.name === sortColumn;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!column?.getSortValue) {
|
||||
return allData;
|
||||
}
|
||||
|
||||
// Sort all available items
|
||||
const sorted = [...allData].sort((a, b) => {
|
||||
const aValue = column.getSortValue!(a);
|
||||
const bValue = column.getSortValue!(b);
|
||||
|
||||
// Handle null values
|
||||
if (aValue === null && bValue === null) return 0;
|
||||
if (aValue === null) return 1;
|
||||
if (bValue === null) return -1;
|
||||
|
||||
// Compare values
|
||||
let comparison = 0;
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
comparison = aValue - bValue;
|
||||
} else {
|
||||
comparison = String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
|
||||
return sortDirection === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [data, sortColumn, sortDirection, columns]).slice(0, 15);
|
||||
|
||||
// Create columns with sortable headers
|
||||
const columnsWithSortableHeaders = useMemo(() => {
|
||||
return columns.map((column, index) => {
|
||||
const columnName =
|
||||
typeof column.name === 'string' ? column.name : String(column.name);
|
||||
const isSortable = !!column.getSortValue;
|
||||
const isSorted = sortColumn === columnName;
|
||||
const currentSortDirection = isSorted ? sortDirection : null;
|
||||
const isRightAligned = index !== 0;
|
||||
|
||||
return {
|
||||
...column,
|
||||
// Add a key property for React keys (using the original column name string)
|
||||
key: columnName,
|
||||
name: isSortable ? (
|
||||
<SortableHeader
|
||||
name={columnName}
|
||||
isSorted={isSorted}
|
||||
sortDirection={currentSortDirection}
|
||||
onClick={() => handleSort(columnName)}
|
||||
isRightAligned={isRightAligned}
|
||||
/>
|
||||
) : (
|
||||
column.name
|
||||
),
|
||||
className: cn(
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right font-mono',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [columns, sortColumn, sortDirection]);
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<WidgetTable
|
||||
data={data ?? []}
|
||||
data={sortedData}
|
||||
keyExtractor={keyExtractor}
|
||||
className={'text-sm min-h-[358px] @container'}
|
||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||
@@ -75,18 +215,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
columns={columns.map((column, index) => {
|
||||
return {
|
||||
...column,
|
||||
className: cn(
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right font-mono',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
})}
|
||||
columns={columnsWithSortableHeaders}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -208,6 +337,8 @@ export function OverviewWidgetTablePages({
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
getSortValue: (item: (typeof data)[number]) =>
|
||||
item.revenue ?? 0,
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
@@ -231,6 +362,7 @@ export function OverviewWidgetTablePages({
|
||||
name: 'Views',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
getSortValue: (item: (typeof data)[number]) => item.pageviews,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -245,6 +377,7 @@ export function OverviewWidgetTablePages({
|
||||
name: 'Sess.',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -339,6 +472,8 @@ export function OverviewWidgetTableEntries({
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
getSortValue: (item: (typeof data)[number]) =>
|
||||
item.revenue ?? 0,
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
@@ -362,6 +497,7 @@ export function OverviewWidgetTableEntries({
|
||||
name: lastColumnName,
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -494,6 +630,9 @@ export function OverviewWidgetTableGeneric({
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
) => item.revenue ?? 0,
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
@@ -521,6 +660,9 @@ export function OverviewWidgetTableGeneric({
|
||||
name: 'Views',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
) => item.pageviews,
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -537,6 +679,9 @@ export function OverviewWidgetTableGeneric({
|
||||
name: 'Sess.',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
getSortValue: (
|
||||
item: RouterOutputs['overview']['topGeneric'][number],
|
||||
) => item.sessions,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -599,6 +744,7 @@ export function OverviewWidgetTableEvents({
|
||||
name: 'Count',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
getSortValue: (item: EventTableItem) => item.count,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface Props<T> {
|
||||
* If not provided, column is always visible.
|
||||
*/
|
||||
responsive?: ColumnResponsive;
|
||||
/**
|
||||
* Function to extract sortable value. If provided, header becomes clickable.
|
||||
*/
|
||||
getSortValue?: (item: T) => number | string | null;
|
||||
/**
|
||||
* Optional key for React keys. If not provided, will try to extract from name or use index.
|
||||
*/
|
||||
key?: string;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
@@ -177,9 +185,16 @@ export function WidgetTable<T>({
|
||||
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
||||
}
|
||||
|
||||
// Use column.key if available, otherwise try to extract string from name, fallback to index
|
||||
const columnKey =
|
||||
column.key ??
|
||||
(typeof column.name === 'string'
|
||||
? column.name
|
||||
: `col-${colIndex}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
key={columnKey}
|
||||
className={cn(
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
@@ -231,9 +246,16 @@ export function WidgetTable<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// Use column.key if available, otherwise try to extract string from name, fallback to index
|
||||
const columnKey =
|
||||
column.key ??
|
||||
(typeof column.name === 'string'
|
||||
? column.name
|
||||
: `col-${colIndex}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
key={columnKey}
|
||||
className={cn(
|
||||
'px-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
|
||||
@@ -109,7 +109,9 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
|
||||
interval: zTimeInterval,
|
||||
});
|
||||
|
||||
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
|
||||
export type IGetTopGenericSeriesInput = z.infer<
|
||||
typeof zGetTopGenericSeriesInput
|
||||
> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
@@ -734,7 +736,7 @@ export class OverviewService {
|
||||
}>;
|
||||
}> {
|
||||
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
||||
const TOP_LIMIT = 15;
|
||||
const TOP_LIMIT = 500;
|
||||
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
||||
|
||||
// Step 1: Get top 15 items
|
||||
|
||||
Reference in New Issue
Block a user