From dd39ff70a91843032c16b0eafc998f3db20a2817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 9 May 2025 20:59:24 +0200 Subject: [PATCH] feature(dashboard): customize event columns --- .../[projectId]/events/conversions.tsx | 20 +- .../[projectId]/events/events.tsx | 2 + .../profiles/[profileId]/profile-events.tsx | 2 + .../src/components/events/table/columns.tsx | 75 +++- .../events/table/events-data-table.tsx | 87 +++-- .../events/table/events-table-columns.tsx | 70 ++++ apps/dashboard/src/components/ui/dialog.tsx | 6 +- apps/dashboard/src/modals/Modal/Container.tsx | 2 +- apps/dashboard/src/modals/event-details.tsx | 368 +++++++++--------- packages/trpc/src/routers/event.ts | 6 + 10 files changed, 408 insertions(+), 230 deletions(-) create mode 100644 apps/dashboard/src/components/events/table/events-table-columns.tsx diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/conversions.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/conversions.tsx index 2f019b59..30725de5 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/conversions.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/conversions.tsx @@ -1,7 +1,10 @@ 'use client'; +import { TableButtons } from '@/components/data-table'; import { EventsTable } from '@/components/events/table'; +import { EventsTableColumns } from '@/components/events/table/events-table-columns'; import { api } from '@/trpc/client'; +import { Loader2Icon } from 'lucide-react'; type Props = { projectId: string; @@ -19,7 +22,22 @@ const Conversions = ({ projectId }: Props) => { }, ); - return ; + return ( +
+ + + {query.isRefetching && ( +
+ +
+ )} +
+ +
+ ); }; export default Conversions; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/events.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/events.tsx index 77272dd8..ab612730 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/events.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/events.tsx @@ -3,6 +3,7 @@ import { TableButtons } from '@/components/data-table'; import EventListener from '@/components/events/event-listener'; import { EventsTable } from '@/components/events/table'; +import { EventsTableColumns } from '@/components/events/table/events-table-columns'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { Button } from '@/components/ui/button'; @@ -74,6 +75,7 @@ const Events = ({ projectId, profileId }: Props) => { enableEventsFilter /> + {query.isRefetching && (
{ enableEventsFilter /> + {query.isRefetching && (
+ {getProfileName(profile)} + + ); } - return ( - - {getProfileName(profile)} - - ); + + if (profileId && profileId !== deviceId) { + return ( + + Unknown + + ); + } + + if (deviceId) { + return ( + + Anonymous + + ); + } + + return null; }, }, + { + accessorKey: 'sessionId', + header: 'Session ID', + size: 320, + }, + { + accessorKey: 'deviceId', + header: 'Device ID', + size: 320, + }, { accessorKey: 'country', header: 'Country', @@ -157,6 +192,24 @@ export function useColumns() { ); }, }, + { + accessorKey: 'properties', + header: 'Properties', + size: 400, + cell({ row }) { + const { properties } = row.original; + const filteredProperties = Object.fromEntries( + Object.entries(properties || {}).filter( + ([key]) => !key.startsWith('__'), + ), + ); + return ( + +
{JSON.stringify(filteredProperties)}
+
+ ); + }, + }, ]; return columns; diff --git a/apps/dashboard/src/components/events/table/events-data-table.tsx b/apps/dashboard/src/components/events/table/events-data-table.tsx index 99baefe4..21e73b88 100644 --- a/apps/dashboard/src/components/events/table/events-data-table.tsx +++ b/apps/dashboard/src/components/events/table/events-data-table.tsx @@ -8,9 +8,10 @@ import { useReactTable, } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table'; -import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual'; +import { useWindowVirtualizer } from '@tanstack/react-virtual'; import throttle from 'lodash.throttle'; import { useEffect, useRef, useState } from 'react'; +import { useEventsTableColumns } from './events-table-columns'; interface DataTableProps { columns: ColumnDef[]; @@ -21,6 +22,7 @@ export function EventsDataTable({ columns, data, }: DataTableProps) { + const [visibleColumns] = useEventsTableColumns(); const table = useReactTable({ data, columns, @@ -74,27 +76,29 @@ export function EventsDataTable({ > {table.getHeaderGroups().map((headerGroup) => (
- {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} + {headerGroup.headers + .filter((header) => visibleColumns.includes(header.id)) + .map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })}
))}
@@ -122,24 +126,27 @@ export function EventsDataTable({ }px)`, }} > - {row.getVisibleCells().map((cell) => { - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); - })} + {row + .getVisibleCells() + .filter((cell) => visibleColumns.includes(cell.column.id)) + .map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })}
); })} diff --git a/apps/dashboard/src/components/events/table/events-table-columns.tsx b/apps/dashboard/src/components/events/table/events-table-columns.tsx new file mode 100644 index 00000000..8ff858b6 --- /dev/null +++ b/apps/dashboard/src/components/events/table/events-table-columns.tsx @@ -0,0 +1,70 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ColumnsIcon } from 'lucide-react'; +import { useQueryState } from 'nuqs'; +import { useLocalStorage } from 'usehooks-ts'; + +// Define available columns +const AVAILABLE_COLUMNS = [ + { id: 'name', label: 'Name' }, + { id: 'createdAt', label: 'Created at' }, + { id: 'profileId', label: 'Profile' }, + { id: 'country', label: 'Country' }, + { id: 'os', label: 'OS' }, + { id: 'browser', label: 'Browser' }, + { id: 'properties', label: 'Properties' }, + { id: 'sessionId', label: 'Session ID' }, + { id: 'deviceId', label: 'Device ID' }, +] as const; + +export function useEventsTableColumns() { + return useLocalStorage('@op:events-table-columns', [ + 'name', + 'createdAt', + 'profileId', + 'country', + 'os', + 'browser', + ]); +} + +export function EventsTableColumns() { + const [visibleColumns, setVisibleColumns] = useEventsTableColumns(); + + return ( + + + + + + Toggle columns + + {AVAILABLE_COLUMNS.map((column) => ( + { + setVisibleColumns( + checked + ? [...visibleColumns, column.id] + : visibleColumns.filter((id) => id !== column.id), + ); + }} + > + {column.label} + + ))} + + + ); +} diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx index d4d85816..47c02d22 100644 --- a/apps/dashboard/src/components/ui/dialog.tsx +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -39,14 +39,16 @@ const DialogContent = React.forwardRef< - {children} +
+ {children} +
)); diff --git a/apps/dashboard/src/modals/Modal/Container.tsx b/apps/dashboard/src/modals/Modal/Container.tsx index 44a405ec..bf685558 100644 --- a/apps/dashboard/src/modals/Modal/Container.tsx +++ b/apps/dashboard/src/modals/Modal/Container.tsx @@ -32,7 +32,7 @@ export function ModalHeader({ return (
typeof item.value === 'string' && item.value); + const data = (() => { + const data = Object.entries(omit(['properties'], event)).map( + ([name, value]) => ({ + name: [name], + value: value as string | number | undefined, + }), + ); - const properties = Object.entries(event.properties) - .map(([name, value]) => ({ - name, - value: value as string | number | undefined, - })) - .filter((item) => typeof item.value === 'string' && item.value); + Object.entries(event.properties).forEach(([name, value]) => { + data.push({ + name: ['properties', ...name.split('.')], + value: value as string | number | undefined, + }); + }); + + return data.filter((item) => { + if (showNullable) { + return true; + } + + return !!item.value; + }); + })(); return ( -
-
- {properties.length > 0 && ( -
-
Params
-
- {properties.map((item) => ( - { - setFilter( - `properties.${item.name}`, - item.value ? String(item.value) : '', - 'is', - ); - }} - /> - ))} -
-
- )} -
-
Common
-
- {common.map((item) => ( - item.onClick?.()} - /> - ))} -
-
+
+ item.name.join('.')} + columns={[ + { + name: 'Name', + className: 'text-left', + width: 'auto', + render(item) { + return ( +
+ {item.name.map((name, index) => ( +
+ {name} +
+ ))} +
+ ); + }, + }, + { + name: 'Value', + className: 'text-right font-mono font-medium', + width: 'auto', + render(item) { + const render = () => { + if ( + item.name[0] === 'duration' && + typeof item.value === 'number' + ) { + return ( +
+ + ({item.value}ms) + {' '} + {number.formatWithUnit(item.value / 1000, 'min')} +
+ ); + } -
-
-
Similar events
- -
- -
+ if ( + isNil(item.value) || + item.value === '' || + item.value === '\x00\x00' + ) { + return
-
; + } + + if (typeof item.value === 'string') { + return
{item.value}
; + } + + if ((item.value as unknown) instanceof Date) { + return ( +
+ {formatDateTime(item.value as unknown as Date)} +
+ ); + } + + return ( +
+ {JSON.stringify(item.value)} +
+ ); + }; + + if ( + item.name[0] && + item.value && + filterable[item.name[0] as keyof typeof filterable] + ) { + return ( + + ); + } + + return render(); + }, + }, + ]} + /> +
+
+
+
+
Similar events
+ +
+ +
); } diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index c6b3b1ce..7c52fdaf 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -96,6 +96,12 @@ export const eventRouter = createTRPCRouter({ ...input, take: 50, cursor: input.cursor ? new Date(input.cursor) : undefined, + select: { + properties: true, + sessionId: true, + deviceId: true, + profileId: true, + }, }); // Hacky join to get profile for entire session