fix: make table rows clickable

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-16 10:45:16 +01:00
parent 4483e464d1
commit eab33d3127
4 changed files with 132 additions and 43 deletions

View File

@@ -1,3 +1,17 @@
import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import EventListener from '../event-listener';
import { useColumns } from './columns';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import {
OverviewFilterButton,
@@ -12,20 +26,6 @@ import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import { CalendarIcon, FilterIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import EventListener from '../event-listener';
import { useColumns } from './columns';
type Props = {
query: UseInfiniteQueryResult<
@@ -54,6 +54,7 @@ interface VirtualRowProps {
scrollMargin: number;
isLoading: boolean;
headerColumnsHash: string;
onRowClick?: (row: any) => void;
}
const VirtualRow = memo(
@@ -63,12 +64,26 @@ const VirtualRow = memo(
headerColumns,
scrollMargin,
isLoading,
onRowClick,
}: VirtualRowProps) {
return (
<div
className={cn(
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
data-index={virtualRow.index}
onClick={
onRowClick
? (e) => {
if ((e.target as HTMLElement).closest('a, button')) {
return;
}
onRowClick(row);
}
: undefined
}
ref={virtualRow.measureElement}
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid',
@@ -83,8 +98,8 @@ const VirtualRow = memo(
const width = `${cell.column.getSize()}px`;
return (
<div
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
key={cell.id}
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
style={{
width,
overflow: 'hidden',
@@ -114,16 +129,18 @@ const VirtualRow = memo(
prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
prevProps.onRowClick === nextProps.onRowClick
);
},
}
);
const VirtualizedEventsTable = ({
table,
data,
isLoading,
}: VirtualizedEventsTableProps) => {
onRowClick,
}: VirtualizedEventsTableProps & { onRowClick?: (row: any) => void }) => {
const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = table.getAllLeafColumns().filter((col) => {
@@ -145,12 +162,12 @@ const VirtualizedEventsTable = ({
const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
return (
<div
className="w-full overflow-x-auto rounded-md border bg-card"
ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
>
{/* Table Header */}
<div
className="sticky top-0 z-10 bg-card border-b"
className="sticky top-0 z-10 border-b bg-card"
style={{
display: 'grid',
gridTemplateColumns: headerColumns
@@ -164,8 +181,8 @@ const VirtualizedEventsTable = ({
const width = `${column.getSize()}px`;
return (
<div
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{
width,
}}
@@ -178,8 +195,8 @@ const VirtualizedEventsTable = ({
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
title="No events"
/>
)}
@@ -194,20 +211,23 @@ const VirtualizedEventsTable = ({
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null;
if (!row) {
return null;
}
return (
<VirtualRow
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
isLoading={isLoading}
key={row.id}
onRowClick={onRowClick}
row={row}
scrollMargin={rowVirtualizer.options.scrollMargin}
virtualRow={{
...virtualRow,
measureElement: rowVirtualizer.measureElement,
}}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/>
);
})}
@@ -220,6 +240,14 @@ export const EventsTable = ({ query, showEventListener = false }: Props) => {
const { isLoading } = query;
const columns = useColumns();
const handleRowClick = useCallback((row: any) => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}, []);
const data = useMemo(() => {
if (isLoading) {
return LOADING_DATA;
@@ -273,13 +301,22 @@ export const EventsTable = ({ query, showEventListener = false }: Props) => {
return (
<>
<EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<EventsTableToolbar
query={query}
showEventListener={showEventListener}
table={table}
/>
<VirtualizedEventsTable
data={data}
isLoading={isLoading}
onRowClick={handleRowClick}
table={table}
/>
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100'
)}
>
<Loader2Icon className="size-4 animate-spin" />
@@ -301,17 +338,17 @@ function EventsTableToolbar({
const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
parseAsIsoDateTime
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return (
<DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2">
{showEventListener && <EventListener onRefresh={() => query.refetch()} />}
{showEventListener && (
<EventListener onRefresh={() => query.refetch()} />
)}
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
@@ -323,6 +360,8 @@ function EventsTableToolbar({
endDate: endDate || undefined,
});
}}
size="sm"
variant="outline"
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`

View File

@@ -11,12 +11,14 @@ import {
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { arePropsEqual } from '@/utils/are-props-equal';
import type { IServiceProfile } from '@openpanel/db';
import { useNavigate } from '@tanstack/react-router';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
import { memo, useCallback } from 'react';
const PAGE_SIZE = 50;
@@ -32,6 +34,22 @@ export const ProfilesTable = memo(
({ type, query, pageSize = PAGE_SIZE }: Props) => {
const { data, isLoading } = query;
const columns = useColumns(type);
const navigate = useNavigate();
const { organizationId, projectId } = useAppParams();
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/profiles/$profileId' as any,
params: {
organizationId,
projectId,
profileId: encodeURIComponent(row.original.id),
},
});
},
[navigate, organizationId, projectId],
);
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
@@ -78,6 +96,7 @@ export const ProfilesTable = memo(
<DataTable
table={table}
loading={isLoading}
onRowClick={handleRowClick}
empty={{
title: 'No profiles',
description: "Looks like you haven't identified any profiles yet.",

View File

@@ -74,7 +74,7 @@ export function useColumns() {
if (session.profile) {
return (
<ProjectLink
className="row items-center gap-2 font-medium"
className="row items-center gap-2 font-medium hover:underline"
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
>
<ProfileAvatar size="sm" {...session.profile} />

View File

@@ -44,17 +44,19 @@ import {
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn';
import type { IServiceSession } from '@openpanel/db';
import { useNavigate } from '@tanstack/react-router';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { Loader2Icon } from 'lucide-react';
import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
type Props = {
@@ -83,6 +85,7 @@ interface VirtualRowProps {
scrollMargin: number;
isLoading: boolean;
headerColumnsHash: string;
onRowClick?: (row: any) => void;
}
const VirtualRow = memo(
@@ -92,12 +95,24 @@ const VirtualRow = memo(
headerColumns,
scrollMargin,
isLoading,
onRowClick,
}: VirtualRowProps) {
return (
<div
data-index={virtualRow.index}
ref={virtualRow.measureElement}
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
className={cn(
'absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row',
onRowClick && 'cursor-pointer',
)}
onClick={
onRowClick
? (e) => {
if ((e.target as HTMLElement).closest('a, button')) return;
onRowClick(row);
}
: undefined
}
style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid',
@@ -143,7 +158,8 @@ const VirtualRow = memo(
prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
prevProps.onRowClick === nextProps.onRowClick
);
},
);
@@ -152,7 +168,8 @@ const VirtualizedSessionsTable = ({
table,
data,
isLoading,
}: VirtualizedSessionsTableProps) => {
onRowClick,
}: VirtualizedSessionsTableProps & { onRowClick?: (row: any) => void }) => {
const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = table.getAllLeafColumns().filter((col) => {
@@ -234,6 +251,7 @@ const VirtualizedSessionsTable = ({
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
onRowClick={onRowClick}
/>
);
})}
@@ -245,6 +263,18 @@ const VirtualizedSessionsTable = ({
export const SessionsTable = ({ query }: Props) => {
const { isLoading } = query;
const columns = useColumns();
const navigate = useNavigate();
const { organizationId, projectId } = useAppParams();
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/sessions/$sessionId' as any,
params: { organizationId, projectId, sessionId: row.original.id },
});
},
[navigate, organizationId, projectId],
);
const data = useMemo(() => {
if (isLoading) {
@@ -304,6 +334,7 @@ export const SessionsTable = ({ query }: Props) => {
table={table}
data={data}
isLoading={isLoading}
onRowClick={handleRowClick}
/>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div