fix: add filters for sessions table
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
|
import type { IServiceProfile } from '@openpanel/db';
|
||||||
import type { UseQueryResult } from '@tanstack/react-query';
|
import type { UseQueryResult } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
import { useColumns } from './columns';
|
import { useColumns } from './columns';
|
||||||
|
|
||||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
import {
|
||||||
|
useDataTableColumnVisibility,
|
||||||
|
useDataTablePagination,
|
||||||
|
} from '@/components/ui/data-table/data-table-hooks';
|
||||||
import {
|
import {
|
||||||
AnimatedSearchInput,
|
AnimatedSearchInput,
|
||||||
DataTableToolbarContainer,
|
DataTableToolbarContainer,
|
||||||
@@ -13,12 +17,8 @@ import {
|
|||||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
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, useCallback } from 'react';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const ProfilesTable = memo(
|
|||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(row: any) => {
|
(row: any) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/$organizationId/$projectId/profiles/$profileId' as any,
|
to: '/$organizationId/$projectId/profiles/$profileId',
|
||||||
params: {
|
params: {
|
||||||
organizationId,
|
organizationId,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -48,7 +48,7 @@ export const ProfilesTable = memo(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[navigate, organizationId, projectId],
|
[navigate, organizationId, projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
||||||
@@ -68,7 +68,7 @@ export const ProfilesTable = memo(
|
|||||||
columns,
|
columns,
|
||||||
rowCount: data?.meta.count,
|
rowCount: data?.meta.count,
|
||||||
pageCount: Math.ceil(
|
pageCount: Math.ceil(
|
||||||
(data?.meta.count || 0) / (pagination.pageSize || 1),
|
(data?.meta.count || 0) / (pagination.pageSize || 1)
|
||||||
),
|
),
|
||||||
filterFns: {
|
filterFns: {
|
||||||
isWithinRange: () => true,
|
isWithinRange: () => true,
|
||||||
@@ -94,18 +94,18 @@ export const ProfilesTable = memo(
|
|||||||
<>
|
<>
|
||||||
<ProfileTableToolbar table={table} />
|
<ProfileTableToolbar table={table} />
|
||||||
<DataTable
|
<DataTable
|
||||||
table={table}
|
|
||||||
loading={isLoading}
|
|
||||||
onRowClick={handleRowClick}
|
|
||||||
empty={{
|
empty={{
|
||||||
title: 'No profiles',
|
title: 'No profiles',
|
||||||
description: "Looks like you haven't identified any profiles yet.",
|
description: "Looks like you haven't identified any profiles yet.",
|
||||||
}}
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
table={table}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
|
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize'])
|
||||||
);
|
);
|
||||||
|
|
||||||
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||||
@@ -113,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
|||||||
return (
|
return (
|
||||||
<DataTableToolbarContainer>
|
<DataTableToolbarContainer>
|
||||||
<AnimatedSearchInput
|
<AnimatedSearchInput
|
||||||
|
onChange={setSearch}
|
||||||
placeholder="Search profiles"
|
placeholder="Search profiles"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
|
||||||
/>
|
/>
|
||||||
<DataTableViewOptions table={table} />
|
<DataTableViewOptions table={table} />
|
||||||
</DataTableToolbarContainer>
|
</DataTableToolbarContainer>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
|
||||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
|
||||||
import { useLocalStorage } from 'usehooks-ts';
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
import { useColumns } from './columns';
|
import { useColumns } from './columns';
|
||||||
|
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||||
|
|
||||||
// Custom hook for persistent column visibility
|
// Custom hook for persistent column visibility
|
||||||
const usePersistentColumnVisibility = (columns: any[]) => {
|
const usePersistentColumnVisibility = (columns: any[]) => {
|
||||||
@@ -21,7 +19,7 @@ const usePersistentColumnVisibility = (columns: any[]) => {
|
|||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, boolean>,
|
{} as Record<string, boolean>
|
||||||
);
|
);
|
||||||
}, [columns, savedVisibility]);
|
}, [columns, savedVisibility]);
|
||||||
|
|
||||||
@@ -37,27 +35,33 @@ const usePersistentColumnVisibility = (columns: any[]) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
|
||||||
import { Skeleton } from '@/components/skeleton';
|
|
||||||
import {
|
|
||||||
AnimatedSearchInput,
|
|
||||||
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 type { IServiceSession } from '@openpanel/db';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon, SlidersHorizontalIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useInViewport } from 'react-in-viewport';
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
AnimatedSearchInput,
|
||||||
|
DataTableToolbarContainer,
|
||||||
|
} from '@/components/ui/data-table/data-table-toolbar';
|
||||||
|
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||||
|
import type { FilterDefinition } from '@/components/ui/filter-dropdown';
|
||||||
|
import { FilterDropdown } from '@/components/ui/filter-dropdown';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import { useSessionFilters } from '@/hooks/use-session-filters';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
query: UseInfiniteQueryResult<
|
query: UseInfiniteQueryResult<
|
||||||
@@ -99,20 +103,22 @@ const VirtualRow = memo(
|
|||||||
}: VirtualRowProps) {
|
}: VirtualRowProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-index={virtualRow.index}
|
|
||||||
ref={virtualRow.measureElement}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row',
|
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
|
||||||
onRowClick && 'cursor-pointer',
|
onRowClick && 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
|
data-index={virtualRow.index}
|
||||||
onClick={
|
onClick={
|
||||||
onRowClick
|
onRowClick
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
if ((e.target as HTMLElement).closest('a, button')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onRowClick(row);
|
onRowClick(row);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
ref={virtualRow.measureElement}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -127,8 +133,8 @@ const VirtualRow = memo(
|
|||||||
const width = `${cell.column.getSize()}px`;
|
const width = `${cell.column.getSize()}px`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -161,7 +167,7 @@ const VirtualRow = memo(
|
|||||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
|
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
|
||||||
prevProps.onRowClick === nextProps.onRowClick
|
prevProps.onRowClick === nextProps.onRowClick
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const VirtualizedSessionsTable = ({
|
const VirtualizedSessionsTable = ({
|
||||||
@@ -188,12 +194,12 @@ const VirtualizedSessionsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="w-full overflow-x-auto rounded-md border bg-card"
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
|
||||||
>
|
>
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<div
|
<div
|
||||||
className="sticky top-0 z-10 bg-card border-b"
|
className="sticky top-0 z-10 border-b bg-card"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: headerColumns
|
gridTemplateColumns: headerColumns
|
||||||
@@ -207,8 +213,8 @@ const VirtualizedSessionsTable = ({
|
|||||||
const width = `${column.getSize()}px`;
|
const width = `${column.getSize()}px`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
}}
|
}}
|
||||||
@@ -221,8 +227,8 @@ const VirtualizedSessionsTable = ({
|
|||||||
|
|
||||||
{!isLoading && data.length === 0 && (
|
{!isLoading && data.length === 0 && (
|
||||||
<FullPageEmptyState
|
<FullPageEmptyState
|
||||||
title="No sessions found"
|
|
||||||
description="Looks like you haven't inserted any events yet."
|
description="Looks like you haven't inserted any events yet."
|
||||||
|
title="No sessions found"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -237,21 +243,23 @@ const VirtualizedSessionsTable = ({
|
|||||||
>
|
>
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
const row = table.getRowModel().rows[virtualRow.index];
|
const row = table.getRowModel().rows[virtualRow.index];
|
||||||
if (!row) return null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualRow
|
<VirtualRow
|
||||||
|
headerColumns={headerColumns}
|
||||||
|
headerColumnsHash={headerColumnsHash}
|
||||||
|
isLoading={isLoading}
|
||||||
key={row.id}
|
key={row.id}
|
||||||
|
onRowClick={onRowClick}
|
||||||
row={row}
|
row={row}
|
||||||
|
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||||
virtualRow={{
|
virtualRow={{
|
||||||
...virtualRow,
|
...virtualRow,
|
||||||
measureElement: rowVirtualizer.measureElement,
|
measureElement: rowVirtualizer.measureElement,
|
||||||
}}
|
}}
|
||||||
headerColumns={headerColumns}
|
|
||||||
headerColumnsHash={headerColumnsHash}
|
|
||||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -269,11 +277,11 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(row: any) => {
|
(row: any) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/$organizationId/$projectId/sessions/$sessionId' as any,
|
to: '/$organizationId/$projectId/sessions/$sessionId',
|
||||||
params: { organizationId, projectId, sessionId: row.original.id },
|
params: { organizationId, projectId, sessionId: row.original.id },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[navigate, organizationId, projectId],
|
[navigate, organizationId, projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
@@ -281,7 +289,7 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
return LOADING_DATA;
|
return LOADING_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.data?.pages?.flatMap((p) => p.data) ?? [];
|
return query.data?.pages?.flatMap((p) => p.items) ?? [];
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
|
|
||||||
// const { setPage, state: pagination } = useDataTablePagination();
|
// const { setPage, state: pagination } = useDataTablePagination();
|
||||||
@@ -322,7 +330,6 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
enterCount > 0 &&
|
enterCount > 0 &&
|
||||||
query.isFetchingNextPage === false
|
query.isFetchingNextPage === false
|
||||||
) {
|
) {
|
||||||
console.log('fetching next page');
|
|
||||||
query.fetchNextPage();
|
query.fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [inViewport, enterCount, hasNextPage]);
|
}, [inViewport, enterCount, hasNextPage]);
|
||||||
@@ -331,16 +338,16 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<SessionTableToolbar table={table} />
|
<SessionTableToolbar table={table} />
|
||||||
<VirtualizedSessionsTable
|
<VirtualizedSessionsTable
|
||||||
table={table}
|
|
||||||
data={data}
|
data={data}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
|
table={table}
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
|
||||||
query.isFetchingNextPage && 'opacity-100',
|
query.isFetchingNextPage && 'opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
@@ -350,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SESSION_FILTER_KEY_TO_FIELD: Record<string, string> = {
|
||||||
|
referrer: 'referrer_name',
|
||||||
|
country: 'country',
|
||||||
|
os: 'os',
|
||||||
|
browser: 'browser',
|
||||||
|
device: 'device',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||||
|
{ key: 'referrer', label: 'Referrer', type: 'select' },
|
||||||
|
{ key: 'country', label: 'Country', type: 'select' },
|
||||||
|
{ key: 'os', label: 'OS', type: 'select' },
|
||||||
|
{ key: 'browser', label: 'Browser', type: 'select' },
|
||||||
|
{ key: 'device', label: 'Device', type: 'select' },
|
||||||
|
{ key: 'entryPage', label: 'Entry page', type: 'string' },
|
||||||
|
{ key: 'exitPage', label: 'Exit page', type: 'string' },
|
||||||
|
{ key: 'minPageViews', label: 'Min page views', type: 'number' },
|
||||||
|
{ key: 'maxPageViews', label: 'Max page views', type: 'number' },
|
||||||
|
{ key: 'minEvents', label: 'Min events', type: 'number' },
|
||||||
|
{ key: 'maxEvents', label: 'Max events', type: 'number' },
|
||||||
|
];
|
||||||
|
|
||||||
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
|
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { search, setSearch } = useSearchQueryState();
|
const { search, setSearch } = useSearchQueryState();
|
||||||
|
const { values, setValue, activeCount } = useSessionFilters();
|
||||||
|
|
||||||
|
const loadOptions = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const field = SESSION_FILTER_KEY_TO_FIELD[key];
|
||||||
|
if (!field) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
return queryClient.fetchQuery(
|
||||||
|
trpc.session.distinctValues.queryOptions({
|
||||||
|
projectId,
|
||||||
|
field: field as
|
||||||
|
| 'referrer_name'
|
||||||
|
| 'country'
|
||||||
|
| 'os'
|
||||||
|
| 'browser'
|
||||||
|
| 'device',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[trpc, queryClient, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableToolbarContainer>
|
<DataTableToolbarContainer>
|
||||||
<AnimatedSearchInput
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
placeholder="Search sessions by path, referrer..."
|
<AnimatedSearchInput
|
||||||
value={search}
|
onChange={setSearch}
|
||||||
onChange={setSearch}
|
placeholder="Search sessions by path, referrer..."
|
||||||
/>
|
value={search}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
definitions={SESSION_FILTER_DEFINITIONS}
|
||||||
|
loadOptions={loadOptions}
|
||||||
|
onChange={setValue}
|
||||||
|
values={values}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'border-dashed',
|
||||||
|
activeCount > 0 && 'border-primary border-solid'
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<SlidersHorizontalIcon className="mr-2 size-4" />
|
||||||
|
Filters
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<Badge className="ml-2 rounded-full px-1.5 py-0 text-xs">
|
||||||
|
{activeCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</FilterDropdown>
|
||||||
|
</div>
|
||||||
<DataTableViewOptions table={table} />
|
<DataTableViewOptions table={table} />
|
||||||
</DataTableToolbarContainer>
|
</DataTableToolbarContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
XIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import VirtualList from 'rc-virtual-list';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export type FilterType = 'select' | 'string' | 'number';
|
||||||
|
|
||||||
|
export interface FilterDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: FilterType;
|
||||||
|
/** For 'select' type: show SerieIcon next to options (default true) */
|
||||||
|
showIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterDropdownProps {
|
||||||
|
definitions: FilterDefinition[];
|
||||||
|
values: Record<string, string | number | null | undefined>;
|
||||||
|
onChange: (key: string, value: string | number | null) => void;
|
||||||
|
loadOptions: (key: string) => Promise<string[]>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterDropdown({
|
||||||
|
definitions,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
loadOptions,
|
||||||
|
children,
|
||||||
|
}: FilterDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
|
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveKey(null);
|
||||||
|
setSearch('');
|
||||||
|
setOptions([]);
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const def = definitions.find((d) => d.key === activeKey);
|
||||||
|
if (!def || def.type !== 'select') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
loadOptions(activeKey)
|
||||||
|
.then((opts) => {
|
||||||
|
setOptions(opts);
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setIsLoading(false));
|
||||||
|
}, [activeKey]);
|
||||||
|
|
||||||
|
const currentDef = activeKey
|
||||||
|
? definitions.find((d) => d.key === activeKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const goToFilter = (key: string) => {
|
||||||
|
setDirection('forward');
|
||||||
|
setSearch('');
|
||||||
|
setOptions([]);
|
||||||
|
const current = values[key];
|
||||||
|
setInputValue(current != null ? String(current) : '');
|
||||||
|
setActiveKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
setDirection('backward');
|
||||||
|
setActiveKey(null);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyValue = (key: string, value: string | number | null) => {
|
||||||
|
onChange(key, value);
|
||||||
|
goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIndex = () => (
|
||||||
|
<div className="min-w-52">
|
||||||
|
{definitions.map((def) => {
|
||||||
|
const currentValue = values[def.key];
|
||||||
|
const isActive = currentValue != null && currentValue !== '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||||
|
key={def.key}
|
||||||
|
onClick={() => goToFilter(def.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm">{def.label}</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<span className="max-w-24 truncate text-muted-foreground text-xs">
|
||||||
|
{String(currentValue)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(def.key, null);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSelectFilter = () => {
|
||||||
|
const showIcon = currentDef?.showIcon !== false;
|
||||||
|
const filteredOptions = options.filter((opt) =>
|
||||||
|
opt.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
const currentValue = activeKey ? values[activeKey] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-52">
|
||||||
|
<div className="flex items-center gap-1 p-1">
|
||||||
|
<Button
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={goBack}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-7 text-sm"
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-6 text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filteredOptions.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||||
|
No options found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<VirtualList
|
||||||
|
data={filteredOptions}
|
||||||
|
height={Math.min(filteredOptions.length * 36, 250)}
|
||||||
|
itemHeight={36}
|
||||||
|
itemKey={(item) => item}
|
||||||
|
>
|
||||||
|
{(option) => (
|
||||||
|
<button
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||||
|
onClick={() => applyValue(activeKey!, option)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showIcon && <SerieIcon name={option} />}
|
||||||
|
<span className="truncate text-sm">{option || 'Direct'}</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
'ml-auto size-4 shrink-0',
|
||||||
|
currentValue === option ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</VirtualList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStringFilter = () => (
|
||||||
|
<div className="min-w-52">
|
||||||
|
<div className="flex items-center gap-1 p-1">
|
||||||
|
<Button
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={goBack}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-8 text-sm"
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
applyValue(activeKey!, inputValue || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
|
||||||
|
value={inputValue}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => applyValue(activeKey!, inputValue || null)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNumberFilter = () => (
|
||||||
|
<div className="min-w-52">
|
||||||
|
<div className="flex items-center gap-1 p-1">
|
||||||
|
<Button
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={goBack}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-8 text-sm"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
applyValue(
|
||||||
|
activeKey!,
|
||||||
|
inputValue === '' ? null : Number(inputValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter value..."
|
||||||
|
type="number"
|
||||||
|
value={inputValue}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() =>
|
||||||
|
applyValue(
|
||||||
|
activeKey!,
|
||||||
|
inputValue === '' ? null : Number(inputValue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!(activeKey && currentDef)) {
|
||||||
|
return renderIndex();
|
||||||
|
}
|
||||||
|
switch (currentDef.type) {
|
||||||
|
case 'select':
|
||||||
|
return renderSelectFilter();
|
||||||
|
case 'string':
|
||||||
|
return renderStringFilter();
|
||||||
|
case 'number':
|
||||||
|
return renderNumberFilter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setOpen} open={open}>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-auto p-1">
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||||
|
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||||
|
key={activeKey ?? 'index'}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
apps/start/src/hooks/use-session-filters.ts
Normal file
229
apps/start/src/hooks/use-session-filters.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import type { IChartEventFilter } from '@openpanel/validation';
|
||||||
|
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
const debounceOpts = {
|
||||||
|
clearOnDefault: true,
|
||||||
|
limitUrlUpdates: { method: 'debounce' as const, timeMs: DEBOUNCE_MS },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSessionFilters() {
|
||||||
|
const [referrer, setReferrer] = useQueryState(
|
||||||
|
'referrer',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [country, setCountry] = useQueryState(
|
||||||
|
'country',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [os, setOs] = useQueryState(
|
||||||
|
'os',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [browser, setBrowser] = useQueryState(
|
||||||
|
'browser',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [device, setDevice] = useQueryState(
|
||||||
|
'device',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [entryPage, setEntryPage] = useQueryState(
|
||||||
|
'entryPage',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [exitPage, setExitPage] = useQueryState(
|
||||||
|
'exitPage',
|
||||||
|
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||||
|
);
|
||||||
|
const [minPageViews, setMinPageViews] = useQueryState(
|
||||||
|
'minPageViews',
|
||||||
|
parseAsInteger,
|
||||||
|
);
|
||||||
|
const [maxPageViews, setMaxPageViews] = useQueryState(
|
||||||
|
'maxPageViews',
|
||||||
|
parseAsInteger,
|
||||||
|
);
|
||||||
|
const [minEvents, setMinEvents] = useQueryState('minEvents', parseAsInteger);
|
||||||
|
const [maxEvents, setMaxEvents] = useQueryState('maxEvents', parseAsInteger);
|
||||||
|
|
||||||
|
const filters = useMemo<IChartEventFilter[]>(() => {
|
||||||
|
const result: IChartEventFilter[] = [];
|
||||||
|
if (referrer) {
|
||||||
|
result.push({ name: 'referrer_name', operator: 'is', value: [referrer] });
|
||||||
|
}
|
||||||
|
if (country) {
|
||||||
|
result.push({ name: 'country', operator: 'is', value: [country] });
|
||||||
|
}
|
||||||
|
if (os) {
|
||||||
|
result.push({ name: 'os', operator: 'is', value: [os] });
|
||||||
|
}
|
||||||
|
if (browser) {
|
||||||
|
result.push({ name: 'browser', operator: 'is', value: [browser] });
|
||||||
|
}
|
||||||
|
if (device) {
|
||||||
|
result.push({ name: 'device', operator: 'is', value: [device] });
|
||||||
|
}
|
||||||
|
if (entryPage) {
|
||||||
|
result.push({
|
||||||
|
name: 'entry_path',
|
||||||
|
operator: 'contains',
|
||||||
|
value: [entryPage],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exitPage) {
|
||||||
|
result.push({
|
||||||
|
name: 'exit_path',
|
||||||
|
operator: 'contains',
|
||||||
|
value: [exitPage],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [referrer, country, os, browser, device, entryPage, exitPage]);
|
||||||
|
|
||||||
|
const values = useMemo(
|
||||||
|
() => ({
|
||||||
|
referrer,
|
||||||
|
country,
|
||||||
|
os,
|
||||||
|
browser,
|
||||||
|
device,
|
||||||
|
entryPage,
|
||||||
|
exitPage,
|
||||||
|
minPageViews,
|
||||||
|
maxPageViews,
|
||||||
|
minEvents,
|
||||||
|
maxEvents,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
referrer,
|
||||||
|
country,
|
||||||
|
os,
|
||||||
|
browser,
|
||||||
|
device,
|
||||||
|
entryPage,
|
||||||
|
exitPage,
|
||||||
|
minPageViews,
|
||||||
|
maxPageViews,
|
||||||
|
minEvents,
|
||||||
|
maxEvents,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(key: string, value: string | number | null) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'referrer':
|
||||||
|
setReferrer(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'country':
|
||||||
|
setCountry(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'os':
|
||||||
|
setOs(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'browser':
|
||||||
|
setBrowser(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'device':
|
||||||
|
setDevice(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'entryPage':
|
||||||
|
setEntryPage(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'exitPage':
|
||||||
|
setExitPage(String(value ?? ''));
|
||||||
|
break;
|
||||||
|
case 'minPageViews':
|
||||||
|
setMinPageViews(value != null ? Number(value) : null);
|
||||||
|
break;
|
||||||
|
case 'maxPageViews':
|
||||||
|
setMaxPageViews(value != null ? Number(value) : null);
|
||||||
|
break;
|
||||||
|
case 'minEvents':
|
||||||
|
setMinEvents(value != null ? Number(value) : null);
|
||||||
|
break;
|
||||||
|
case 'maxEvents':
|
||||||
|
setMaxEvents(value != null ? Number(value) : null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setReferrer,
|
||||||
|
setCountry,
|
||||||
|
setOs,
|
||||||
|
setBrowser,
|
||||||
|
setDevice,
|
||||||
|
setEntryPage,
|
||||||
|
setExitPage,
|
||||||
|
setMinPageViews,
|
||||||
|
setMaxPageViews,
|
||||||
|
setMinEvents,
|
||||||
|
setMaxEvents,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount =
|
||||||
|
filters.length +
|
||||||
|
(minPageViews != null ? 1 : 0) +
|
||||||
|
(maxPageViews != null ? 1 : 0) +
|
||||||
|
(minEvents != null ? 1 : 0) +
|
||||||
|
(maxEvents != null ? 1 : 0);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setReferrer('');
|
||||||
|
setCountry('');
|
||||||
|
setOs('');
|
||||||
|
setBrowser('');
|
||||||
|
setDevice('');
|
||||||
|
setEntryPage('');
|
||||||
|
setExitPage('');
|
||||||
|
setMinPageViews(null);
|
||||||
|
setMaxPageViews(null);
|
||||||
|
setMinEvents(null);
|
||||||
|
setMaxEvents(null);
|
||||||
|
}, [
|
||||||
|
setReferrer,
|
||||||
|
setCountry,
|
||||||
|
setOs,
|
||||||
|
setBrowser,
|
||||||
|
setDevice,
|
||||||
|
setEntryPage,
|
||||||
|
setExitPage,
|
||||||
|
setMinPageViews,
|
||||||
|
setMaxPageViews,
|
||||||
|
setMinEvents,
|
||||||
|
setMaxEvents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
referrer,
|
||||||
|
setReferrer,
|
||||||
|
country,
|
||||||
|
setCountry,
|
||||||
|
os,
|
||||||
|
setOs,
|
||||||
|
browser,
|
||||||
|
setBrowser,
|
||||||
|
device,
|
||||||
|
setDevice,
|
||||||
|
entryPage,
|
||||||
|
setEntryPage,
|
||||||
|
exitPage,
|
||||||
|
setExitPage,
|
||||||
|
minPageViews,
|
||||||
|
setMinPageViews,
|
||||||
|
maxPageViews,
|
||||||
|
setMaxPageViews,
|
||||||
|
minEvents,
|
||||||
|
setMinEvents,
|
||||||
|
maxEvents,
|
||||||
|
setMaxEvents,
|
||||||
|
filters,
|
||||||
|
values,
|
||||||
|
setValue,
|
||||||
|
activeCount,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { SessionsTable } from '@/components/sessions/table';
|
import { SessionsTable } from '@/components/sessions/table';
|
||||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import { useSessionFilters } from '@/hooks/use-session-filters';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useInfiniteQuery,
|
|
||||||
useQuery,
|
|
||||||
} from '@tanstack/react-query';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/sessions',
|
'/_app/$organizationId/$projectId/sessions'
|
||||||
)({
|
)({
|
||||||
component: Component,
|
component: Component,
|
||||||
head: () => {
|
head: () => {
|
||||||
@@ -31,6 +27,8 @@ function Component() {
|
|||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { debouncedSearch } = useSearchQueryState();
|
const { debouncedSearch } = useSearchQueryState();
|
||||||
|
const { filters, minPageViews, maxPageViews, minEvents, maxEvents } =
|
||||||
|
useSessionFilters();
|
||||||
|
|
||||||
const query = useInfiniteQuery(
|
const query = useInfiniteQuery(
|
||||||
trpc.session.list.infiniteQueryOptions(
|
trpc.session.list.infiniteQueryOptions(
|
||||||
@@ -38,19 +36,24 @@ function Component() {
|
|||||||
projectId,
|
projectId,
|
||||||
take: 50,
|
take: 50,
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
|
filters,
|
||||||
|
minPageViews,
|
||||||
|
maxPageViews,
|
||||||
|
minEvents,
|
||||||
|
maxEvents,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
},
|
}
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Sessions"
|
|
||||||
description="Access all your sessions here"
|
|
||||||
className="mb-8"
|
className="mb-8"
|
||||||
|
description="Access all your sessions here"
|
||||||
|
title="Sessions"
|
||||||
/>
|
/>
|
||||||
<SessionsTable query={query} />
|
<SessionsTable query={query} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -53,7 +53,9 @@
|
|||||||
"noUnusedTemplateLiteral": "error",
|
"noUnusedTemplateLiteral": "error",
|
||||||
"useNumberNamespace": "error",
|
"useNumberNamespace": "error",
|
||||||
"noInferrableTypes": "error",
|
"noInferrableTypes": "error",
|
||||||
"noUselessElse": "error"
|
"noUselessElse": "error",
|
||||||
|
"noNestedTernary": "off",
|
||||||
|
"useDefaultSwitchClause": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useExhaustiveDependencies": "off",
|
"useExhaustiveDependencies": "off",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
import { getOrganizationByProjectIdCached } from './organization.service';
|
|
||||||
import { getProfilesCached, type IServiceProfile } from './profile.service';
|
import { getProfilesCached, type IServiceProfile } from './profile.service';
|
||||||
|
|
||||||
export interface IClickhouseSession {
|
export interface IClickhouseSession {
|
||||||
@@ -106,7 +105,12 @@ export interface GetSessionListOptions {
|
|||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
search?: string;
|
search?: string;
|
||||||
cursor?: Cursor | null;
|
cursor?: Date;
|
||||||
|
minPageViews?: number | null;
|
||||||
|
maxPageViews?: number | null;
|
||||||
|
minEvents?: number | null;
|
||||||
|
maxEvents?: number | null;
|
||||||
|
dateIntervalInDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformSession(session: IClickhouseSession): IServiceSession {
|
export function transformSession(session: IClickhouseSession): IServiceSession {
|
||||||
@@ -151,35 +155,51 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageInfo {
|
export async function getSessionList(options: GetSessionListOptions) {
|
||||||
next?: Cursor; // use last row
|
const {
|
||||||
}
|
cursor,
|
||||||
|
take,
|
||||||
|
projectId,
|
||||||
|
profileId,
|
||||||
|
filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search,
|
||||||
|
minPageViews,
|
||||||
|
maxPageViews,
|
||||||
|
minEvents,
|
||||||
|
maxEvents,
|
||||||
|
dateIntervalInDays = 0.5,
|
||||||
|
} = options;
|
||||||
|
|
||||||
interface Cursor {
|
|
||||||
createdAt: string; // ISO 8601 with ms
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionList({
|
|
||||||
cursor,
|
|
||||||
take,
|
|
||||||
projectId,
|
|
||||||
profileId,
|
|
||||||
filters,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
search,
|
|
||||||
}: GetSessionListOptions) {
|
|
||||||
const { sb, getSql } = createSqlBuilder();
|
const { sb, getSql } = createSqlBuilder();
|
||||||
|
|
||||||
sb.from = `${TABLE_NAMES.sessions} FINAL`;
|
sb.from = `${TABLE_NAMES.sessions} FINAL`;
|
||||||
sb.limit = take;
|
sb.limit = take;
|
||||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||||
|
|
||||||
if (startDate && endDate) {
|
const MAX_DATE_INTERVAL_IN_DAYS = 365;
|
||||||
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
|
// Cap the date interval to prevent infinity
|
||||||
|
const safeDateIntervalInDays = Math.min(
|
||||||
|
dateIntervalInDays,
|
||||||
|
MAX_DATE_INTERVAL_IN_DAYS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cursor instanceof Date) {
|
||||||
|
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
|
||||||
|
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(cursor || (startDate && endDate))) {
|
||||||
|
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.orderBy.created_at = 'created_at DESC';
|
||||||
|
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
||||||
}
|
}
|
||||||
@@ -190,27 +210,19 @@ export async function getSessionList({
|
|||||||
if (filters?.length) {
|
if (filters?.length) {
|
||||||
Object.assign(sb.where, getEventFiltersWhereClause(filters));
|
Object.assign(sb.where, getEventFiltersWhereClause(filters));
|
||||||
}
|
}
|
||||||
|
if (minPageViews != null) {
|
||||||
const organization = await getOrganizationByProjectIdCached(projectId);
|
sb.where.minPageViews = `screen_view_count >= ${minPageViews}`;
|
||||||
// This will speed up the query quite a lot for big organizations
|
}
|
||||||
const dateIntervalInDays =
|
if (maxPageViews != null) {
|
||||||
organization?.subscriptionPeriodEventsLimit &&
|
sb.where.maxPageViews = `screen_view_count <= ${maxPageViews}`;
|
||||||
organization?.subscriptionPeriodEventsLimit > 1_000_000
|
}
|
||||||
? 2
|
if (minEvents != null) {
|
||||||
: 360;
|
sb.where.minEvents = `event_count >= ${minEvents}`;
|
||||||
|
}
|
||||||
if (cursor) {
|
if (maxEvents != null) {
|
||||||
const cAt = sqlstring.escape(cursor.createdAt);
|
sb.where.maxEvents = `event_count <= ${maxEvents}`;
|
||||||
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
|
|
||||||
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
|
|
||||||
sb.orderBy.created_at = 'created_at DESC';
|
|
||||||
} else {
|
|
||||||
sb.orderBy.created_at = 'created_at DESC';
|
|
||||||
sb.where.created_at = `created_at > now() - INTERVAL ${dateIntervalInDays} DAY`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Select columns (as you had) ====
|
|
||||||
// sb.select.id = 'id'; sb.select.project_id = 'project_id'; ... etc.
|
|
||||||
const columns = [
|
const columns = [
|
||||||
'created_at',
|
'created_at',
|
||||||
'ended_at',
|
'ended_at',
|
||||||
@@ -249,17 +261,17 @@ export async function getSessionList({
|
|||||||
}
|
}
|
||||||
>(sql);
|
>(sql);
|
||||||
|
|
||||||
// Compute cursors from page edges
|
// If no results and we haven't reached the max window, retry with a larger interval
|
||||||
const last = data[take - 1];
|
if (
|
||||||
|
data.length === 0 &&
|
||||||
const meta: PageInfo = {
|
sb.where.cursorWindow &&
|
||||||
next: last
|
safeDateIntervalInDays < MAX_DATE_INTERVAL_IN_DAYS
|
||||||
? {
|
) {
|
||||||
createdAt: last.created_at,
|
return getSessionList({
|
||||||
id: last.id,
|
...options,
|
||||||
}
|
dateIntervalInDays: dateIntervalInDays * 2,
|
||||||
: undefined,
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// Profile hydration (unchanged)
|
// Profile hydration (unchanged)
|
||||||
const profileIds = data
|
const profileIds = data
|
||||||
@@ -283,6 +295,13 @@ export async function getSessionList({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Compute cursors from page edges
|
||||||
|
const last = items.at(-1);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
next: last ? last.createdAt.toISOString() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
return { items, meta };
|
return { items, meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +389,41 @@ export async function getSessionReplayChunksFrom(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SESSION_DISTINCT_FIELDS = [
|
||||||
|
'referrer_name',
|
||||||
|
'country',
|
||||||
|
'os',
|
||||||
|
'browser',
|
||||||
|
'device',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SessionDistinctField = (typeof SESSION_DISTINCT_FIELDS)[number];
|
||||||
|
|
||||||
|
export async function getSessionDistinctValues(
|
||||||
|
projectId: string,
|
||||||
|
field: SessionDistinctField,
|
||||||
|
limit = 200
|
||||||
|
): Promise<string[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT ${field} AS value, count() AS cnt
|
||||||
|
FROM ${TABLE_NAMES.sessions}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND ${field} != ''
|
||||||
|
AND sign = 1
|
||||||
|
AND created_at > now() - INTERVAL 90 DAY
|
||||||
|
GROUP BY value
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
const results = await chQuery<{ value: string }>(sql);
|
||||||
|
return results.map((r) => r.value).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
class SessionService {
|
class SessionService {
|
||||||
constructor(private client: typeof ch) {}
|
private readonly client: typeof ch;
|
||||||
|
constructor(client: typeof ch) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
async byId(sessionId: string, projectId: string) {
|
async byId(sessionId: string, projectId: string) {
|
||||||
const [sessionRows, hasReplayRows] = await Promise.all([
|
const [sessionRows, hasReplayRows] = await Promise.all([
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
getSessionDistinctValues,
|
||||||
getSessionList,
|
getSessionList,
|
||||||
getSessionReplayChunksFrom,
|
getSessionReplayChunksFrom,
|
||||||
|
SESSION_DISTINCT_FIELDS,
|
||||||
sessionService,
|
sessionService,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { zChartEventFilter } from '@openpanel/validation';
|
import { zChartEventFilter } from '@openpanel/validation';
|
||||||
@@ -42,20 +44,28 @@ export const sessionRouter = createTRPCRouter({
|
|||||||
endDate: z.date().optional(),
|
endDate: z.date().optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
take: z.number().default(50),
|
take: z.number().default(50),
|
||||||
|
minPageViews: z.number().nullish(),
|
||||||
|
maxPageViews: z.number().nullish(),
|
||||||
|
minEvents: z.number().nullish(),
|
||||||
|
maxEvents: z.number().nullish(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(({ input }) => {
|
||||||
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
|
return getSessionList({
|
||||||
const data = await getSessionList({
|
|
||||||
...input,
|
...input,
|
||||||
cursor,
|
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
||||||
});
|
});
|
||||||
return {
|
}),
|
||||||
data: data.items,
|
|
||||||
meta: {
|
distinctValues: protectedProcedure
|
||||||
next: data.meta.next ? encodeCursor(data.meta.next) : undefined,
|
.input(
|
||||||
},
|
z.object({
|
||||||
};
|
projectId: z.string(),
|
||||||
|
field: z.enum(SESSION_DISTINCT_FIELDS),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(({ input }) => {
|
||||||
|
return getSessionDistinctValues(input.projectId, input.field);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure
|
byId: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user