This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 09:00:10 +01:00
parent 765e4aa107
commit 90881e5ffb
68 changed files with 4092 additions and 1694 deletions

View File

@@ -76,7 +76,6 @@ export function useColumns() {
<span className="flex min-w-0 flex-1 gap-2">
<button
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
title={fullTitle}
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
@@ -84,6 +83,7 @@ export function useColumns() {
projectId: row.original.projectId,
});
}}
title={fullTitle}
type="button"
>
<span className="block truncate">{renderName()}</span>
@@ -204,6 +204,32 @@ export function useColumns() {
);
},
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
key={g}
>
{g}
</span>
))}
</div>
);
},
},
{
accessorKey: 'properties',
header: 'Properties',

View File

@@ -0,0 +1,53 @@
import { useAppParams } from '@/hooks/use-app-params';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { Badge } from '@/components/ui/badge';
import { Link } from '@tanstack/react-router';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceGroup } from '@openpanel/db';
export function useGroupColumns(): ColumnDef<IServiceGroup>[] {
const { organizationId, projectId } = useAppParams();
return [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
const group = row.original;
return (
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
);
},
},
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{row.original.id}
</span>
),
},
{
accessorKey: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge variant="outline">{row.original.type}</Badge>
),
},
{
accessorKey: 'createdAt',
header: 'Created',
size: ColumnCreatedAt.size,
cell: ({ row }) => (
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
),
},
];
}

View File

@@ -0,0 +1,114 @@
import type { IServiceGroup } from '@openpanel/db';
import type { UseQueryResult } from '@tanstack/react-query';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
import { useGroupColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
useDataTableColumnVisibility,
useDataTablePagination,
} from '@/components/ui/data-table/data-table-hooks';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import type { RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
const PAGE_SIZE = 50;
interface Props {
query: UseQueryResult<RouterOutputs['group']['list'], unknown>;
pageSize?: number;
toolbarLeft?: React.ReactNode;
}
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroup[];
export const GroupsTable = memo(
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
const { data, isLoading } = query;
const columns = useGroupColumns();
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
} = useDataTableColumnVisibility(columns, 'groups');
const table = useReactTable({
data: isLoading ? LOADING_DATA : (data?.data ?? []),
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
columns,
rowCount: data?.meta.count,
pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1)
),
filterFns: {
isWithinRange: () => true,
},
state: {
pagination,
columnVisibility,
columnOrder,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue;
setPage(nextPagination.pageIndex + 1);
},
getRowId: (row, index) => (row as IServiceGroup).id ?? `loading-${index}`,
});
return (
<>
<GroupsTableToolbar table={table} toolbarLeft={toolbarLeft} />
<DataTable
empty={{
title: 'No groups found',
description:
'Groups represent companies, teams, or other entities that events belong to.',
}}
loading={isLoading}
table={table}
/>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'pageSize', 'toolbarLeft'])
);
function GroupsTableToolbar({
table,
toolbarLeft,
}: {
table: Table<IServiceGroup>;
toolbarLeft?: React.ReactNode;
}) {
const { search, setSearch } = useSearchQueryState();
return (
<DataTableToolbarContainer>
<div className="flex flex-wrap items-center gap-2">
{toolbarLeft}
<AnimatedSearchInput
onChange={setSearch}
placeholder="Search groups..."
value={search}
/>
</div>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);
}

View File

@@ -0,0 +1,52 @@
import { ProjectLink } from '@/components/links';
import { Widget } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { WidgetHead } from '../overview/overview-widget';
import { useQuery } from '@tanstack/react-query';
import { FullPageEmptyState } from '../full-page-empty-state';
type Props = {
profileId: string;
projectId: string;
groups: string[];
};
export const ProfileGroups = ({ projectId, groups }: Props) => {
const trpc = useTRPC();
const query = useQuery(
trpc.group.listByIds.queryOptions({
projectId,
ids: groups,
}),
);
if (!groups.length) return null;
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Groups</div>
</WidgetHead>
{query.data?.length ? (
<div className="flex flex-wrap gap-2 p-4">
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 hover:bg-muted transition-colors"
>
<div>
<div className="text-sm font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
{group.type} · {group.id}
</div>
</div>
</ProjectLink>
))}
</div>
) : query.isLoading ? null : (
<FullPageEmptyState title="No groups found" className="p-4" />
)}
</Widget>
);
};

View File

@@ -1,15 +1,10 @@
import type { IServiceProfile } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ProfileAvatar } from '../profile-avatar';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tooltiper } from '@/components/ui/tooltip';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceProfile } from '@openpanel/db';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '../profile-avatar';
export function useColumns(type: 'profiles' | 'power-users') {
const columns: ColumnDef<IServiceProfile>[] = [
@@ -20,8 +15,8 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original;
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="flex items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(profile.id)}`}
title={getProfileName(profile, false)}
>
<ProfileAvatar size="sm" {...profile} />
@@ -100,13 +95,40 @@ export function useColumns(type: 'profiles' | 'power-users') {
},
{
accessorKey: 'createdAt',
header: 'Last seen',
header: 'First seen',
size: ColumnCreatedAt.size,
cell: ({ row }) => {
const item = row.original;
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
},
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<ProjectLink
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline"
href={`/groups/${encodeURIComponent(g)}`}
key={g}
>
{g}
</ProjectLink>
))}
</div>
);
},
},
];
if (type === 'power-users') {

View File

@@ -166,7 +166,8 @@ export function Tables({
metric: 'sum',
options: funnelOptions,
},
stepIndex, // Pass the step index for funnel queries
stepIndex,
breakdownValues: breakdowns,
});
};
return (

View File

@@ -270,6 +270,27 @@ export function useColumns() {
header: 'Device ID',
size: 120,
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
cell: ({ row }) => {
const { groups } = row.original;
if (!groups?.length) return null;
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
key={g}
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"
>
{g}
</span>
))}
</div>
);
},
},
];
return columns;

View File

@@ -1,7 +1,6 @@
import type { Column, Table } from '@tanstack/react-table';
import { SearchIcon, X, XIcon } from 'lucide-react';
import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
@@ -23,12 +22,12 @@ export function DataTableToolbarContainer({
}: React.ComponentProps<'div'>) {
return (
<div
role="toolbar"
aria-orientation="horizontal"
className={cn(
'flex flex-1 items-start justify-between gap-2 mb-2',
className,
'mb-2 flex flex-1 items-start justify-between gap-2',
className
)}
role="toolbar"
{...props}
/>
);
@@ -47,12 +46,12 @@ export function DataTableToolbar<TData>({
});
const isFiltered = table.getState().columnFilters.length > 0;
const columns = React.useMemo(
const columns = useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table],
[table]
);
const onReset = React.useCallback(() => {
const onReset = useCallback(() => {
table.resetColumnFilters();
}, [table]);
@@ -61,23 +60,23 @@ export function DataTableToolbar<TData>({
<div className="flex flex-1 flex-wrap items-center gap-2">
{globalSearchKey && (
<AnimatedSearchInput
onChange={setSearch}
placeholder={globalSearchPlaceholder ?? 'Search'}
value={search}
onChange={setSearch}
/>
)}
{columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} />
<DataTableToolbarFilter column={column} key={column.id} />
))}
{isFiltered && (
<Button
aria-label="Reset filters"
variant="outline"
size="sm"
className="border-dashed"
onClick={onReset}
size="sm"
variant="outline"
>
<XIcon className="size-4 mr-2" />
<XIcon className="mr-2 size-4" />
Reset
</Button>
)}
@@ -99,20 +98,22 @@ function DataTableToolbarFilter<TData>({
{
const columnMeta = column.columnDef.meta;
const getTitle = React.useCallback(() => {
const getTitle = useCallback(() => {
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
}, [columnMeta, column]);
const onFilterRender = React.useCallback(() => {
if (!columnMeta?.variant) return null;
const onFilterRender = useCallback(() => {
if (!columnMeta?.variant) {
return null;
}
switch (columnMeta.variant) {
case 'text':
return (
<AnimatedSearchInput
onChange={(value) => column.setFilterValue(value)}
placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''}
onChange={(value) => column.setFilterValue(value)}
/>
);
@@ -120,12 +121,12 @@ function DataTableToolbarFilter<TData>({
return (
<div className="relative">
<Input
type="number"
inputMode="numeric"
placeholder={getTitle()}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
inputMode="numeric"
onChange={(event) => column.setFilterValue(event.target.value)}
placeholder={getTitle()}
type="number"
value={(column.getFilterValue() as string) ?? ''}
/>
{columnMeta.unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
@@ -143,8 +144,8 @@ function DataTableToolbarFilter<TData>({
return (
<DataTableDateFilter
column={column}
title={getTitle()}
multiple={columnMeta.variant === 'dateRange'}
title={getTitle()}
/>
);
@@ -153,9 +154,9 @@ function DataTableToolbarFilter<TData>({
return (
<DataTableFacetedFilter
column={column}
title={getTitle()}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === 'multiSelect'}
options={columnMeta.options ?? []}
title={getTitle()}
/>
);
@@ -179,11 +180,11 @@ export function AnimatedSearchInput({
value,
onChange,
}: AnimatedSearchInputProps) {
const [isFocused, setIsFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const isExpanded = isFocused || (value?.length ?? 0) > 0;
const handleClear = React.useCallback(() => {
const handleClear = useCallback(() => {
onChange('');
// Re-focus after clearing
requestAnimationFrame(() => inputRef.current?.focus());
@@ -191,34 +192,35 @@ export function AnimatedSearchInput({
return (
<div
aria-label={placeholder ?? 'Search'}
className={cn(
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'relative flex items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
isExpanded ? 'w-56 lg:w-72' : 'w-32',
'h-8 min-h-8',
isExpanded ? 'w-56 lg:w-72' : 'w-32'
)}
role="search"
aria-label={placeholder ?? 'Search'}
>
<SearchIcon className="size-4 ml-2 shrink-0" />
<SearchIcon className="ml-2 size-4 shrink-0" />
<Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none',
'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'transition-opacity duration-200',
'font-medium text-[14px] truncate align-baseline',
'truncate align-baseline font-medium text-[14px]'
)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
placeholder={placeholder}
ref={inputRef}
size="sm"
value={value}
/>
{isExpanded && value && (
<button
type="button"
aria-label="Clear search"
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={(e) => {
@@ -226,6 +228,7 @@ export function AnimatedSearchInput({
e.stopPropagation();
handleClear();
}}
type="button"
>
<X className="size-4" />
</button>

View File

@@ -1,7 +1,6 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Select({
@@ -32,12 +31,12 @@ function SelectTrigger({
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-8 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-size={size}
data-slot="select-trigger"
{...props}
>
{children}
@@ -57,13 +56,13 @@ function SelectContent({
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className
)}
data-slot="select-content"
position={position}
{...props}
>
@@ -72,7 +71,7 @@ function SelectContent({
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
@@ -89,8 +88,8 @@ function SelectLabel({
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn('px-2 py-1.5 text-muted-foreground text-xs', className)}
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
@@ -103,11 +102,11 @@ function SelectItem({
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
data-slot="select-item"
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -126,8 +125,8 @@ function SelectSeparator({
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
@@ -139,11 +138,11 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
className
)}
data-slot="select-scroll-up-button"
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -157,11 +156,11 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
className
)}
data-slot="select-scroll-down-button"
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -0,0 +1,118 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface IForm {
id: string;
type: string;
name: string;
properties: { key: string; value: string }[];
}
export default function AddGroup() {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
defaultValues: {
id: '',
type: '',
name: '',
properties: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.create.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group created.' });
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Add group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties, ...values }) => {
const props = Object.fromEntries(
properties
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]),
);
mutation.mutate({ projectId, ...values, properties: props });
})}
>
<InputWithLabel label="ID" placeholder="acme-corp" {...register('id', { required: true })} autoFocus />
<InputWithLabel label="Name" placeholder="Acme Corp" {...register('name', { required: true })} />
<InputWithLabel label="Type" placeholder="company" {...register('type', { required: true })} />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,120 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { IServiceGroup } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface IForm {
type: string;
name: string;
properties: { key: string; value: string }[];
}
type EditGroupProps = Pick<IServiceGroup, 'id' | 'projectId' | 'name' | 'type' | 'properties'>;
export default function EditGroup({ id, projectId, name, type, properties }: EditGroupProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
defaultValues: {
type,
name,
properties: Object.entries(properties as Record<string, string>).map(([key, value]) => ({
key,
value: String(value),
})),
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.update.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.byId.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group updated.' });
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Edit group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties: formProps, ...values }) => {
const props = Object.fromEntries(
formProps
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]),
);
mutation.mutate({ id, projectId, ...values, properties: props });
})}
>
<InputWithLabel label="Name" {...register('name', { required: true })} />
<InputWithLabel label="Type" {...register('type', { required: true })} />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -1,7 +1,7 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
import AddGroup from './add-group';
import AddImport from './add-import';
import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule';
@@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker';
import EditClient from './edit-client';
import EditDashboard from './edit-dashboard';
import EditEvent from './edit-event';
import EditGroup from './edit-group';
import EditMember from './edit-member';
import EditReference from './edit-reference';
import EditReport from './edit-report';
@@ -23,6 +24,7 @@ import EventDetails from './event-details';
import Instructions from './Instructions';
import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters';
import PageDetails from './page-details';
import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan';
@@ -36,6 +38,8 @@ import { op } from '@/utils/op';
const modals = {
PageDetails,
AddGroup,
EditGroup,
OverviewTopPagesModal,
OverviewTopGenericModal,
RequestPasswordReset,

View File

@@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
interface FunnelUsersViewProps {
report: IReportInput;
stepIndex: number;
breakdownValues?: string[];
}
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) {
const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false);
@@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
? report.options.funnelGroup
: undefined,
breakdowns: report.breakdowns,
breakdownValues: breakdownValues,
},
{
enabled: stepIndex !== undefined,
@@ -384,13 +386,14 @@ type ViewChartUsersProps =
type: 'funnel';
report: IReportInput;
stepIndex: number;
breakdownValues?: string[];
};
// Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') {
return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} breakdownValues={props.breakdownValues} />
);
}

View File

@@ -64,7 +64,6 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from
import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId'
import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId'
import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs'
import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId'
import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index'
@@ -84,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute(
'/_app/$organizationId/profile',
@@ -115,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId',
)()
const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId',
)()
const UnsubscribeRoute = UnsubscribeRouteImport.update({
id: '/unsubscribe',
@@ -376,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
path: '/$profileId',
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProfileTabsIndexRoute =
AppOrganizationIdProfileTabsIndexRouteImport.update({
id: '/',
@@ -451,12 +463,6 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute =
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsRoute =
AppOrganizationIdProjectIdEventsTabsRouteImport.update({
id: '/_tabs',
@@ -569,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
path: '/notifications',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsStatsRoute =
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
id: '/stats',
@@ -593,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions',
@@ -605,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({
id: '/events',
path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -645,7 +674,6 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -662,6 +690,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
@@ -679,8 +708,11 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRoutesByTo {
@@ -720,7 +752,6 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -734,6 +765,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
@@ -747,6 +779,8 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
}
@@ -798,7 +832,6 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren
'/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
@@ -818,6 +851,8 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
@@ -836,8 +871,11 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRouteTypes {
@@ -881,7 +919,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -898,6 +935,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -915,8 +953,11 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/groups/$groupId/'
| '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -956,7 +997,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -970,6 +1010,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -983,6 +1024,8 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
id:
@@ -1033,7 +1076,6 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/dashboards_/$dashboardId'
| '/_app/$organizationId/$projectId/events'
| '/_app/$organizationId/$projectId/events/_tabs'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs'
| '/_app/$organizationId/$projectId/profiles'
@@ -1053,6 +1095,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
| '/_app/$organizationId/$projectId/events/_tabs/events'
| '/_app/$organizationId/$projectId/events/_tabs/stats'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
| '/_app/$organizationId/$projectId/profiles/$profileId'
@@ -1071,8 +1115,11 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById
}
@@ -1432,6 +1479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId': {
id: '/_app/$organizationId/$projectId/groups_/$groupId'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/profile/_tabs/': {
id: '/_app/$organizationId/profile/_tabs/'
path: '/'
@@ -1523,13 +1577,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId': {
id: '/_app/$organizationId/$projectId/groups_/$groupId'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/events/_tabs': {
id: '/_app/$organizationId/$projectId/events/_tabs'
path: '/events'
@@ -1663,6 +1710,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
}
'/_app/$organizationId/$projectId/events/_tabs/stats': {
id: '/_app/$organizationId/$projectId/events/_tabs/stats'
path: '/stats'
@@ -1691,6 +1745,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
path: '/'
fullPath: '/$organizationId/$projectId/groups/$groupId/'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
path: '/sessions'
@@ -1705,6 +1766,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
path: '/members'
fullPath: '/$organizationId/$projectId/groups/$groupId/members'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
path: '/events'
fullPath: '/$organizationId/$projectId/groups/$groupId/events'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
}
}
@@ -1912,6 +1987,42 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
AppOrganizationIdProjectIdSettingsRouteChildren,
)
interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute,
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren,
)
interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren,
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdRouteChildren,
)
interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
@@ -1926,12 +2037,12 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
}
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
@@ -1958,8 +2069,6 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdDashboardsDashboardIdRoute,
AppOrganizationIdProjectIdEventsRoute:
AppOrganizationIdProjectIdEventsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRoute,
AppOrganizationIdProjectIdNotificationsRoute:
AppOrganizationIdProjectIdNotificationsRouteWithChildren,
AppOrganizationIdProjectIdProfilesRoute:
@@ -1970,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdSessionsSessionIdRoute,
AppOrganizationIdProjectIdSettingsRoute:
AppOrganizationIdProjectIdSettingsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
}
const AppOrganizationIdProjectIdRouteWithChildren =

View File

@@ -1,11 +1,12 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon } from 'lucide-react';
import { createFileRoute } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { GroupsTable } from '@/components/groups/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
Select,
SelectContent,
@@ -15,9 +16,11 @@ import {
} from '@/components/ui/select';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { pushModal } from '@/modals';
import { createProjectTitle } from '@/utils/title';
const PAGE_SIZE = 50;
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
{
component: Component,
@@ -28,13 +31,14 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
);
function Component() {
const { projectId, organizationId } = Route.useParams();
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { search, setSearch, debouncedSearch } = useSearchQueryState();
const { debouncedSearch } = useSearchQueryState();
const [typeFilter, setTypeFilter] = useQueryState(
'type',
parseAsString.withDefault('')
);
const { page } = useDataTablePagination(PAGE_SIZE);
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
@@ -44,103 +48,53 @@ function Component() {
projectId,
search: debouncedSearch || undefined,
type: typeFilter || undefined,
take: 100,
take: PAGE_SIZE,
cursor: (page - 1) * PAGE_SIZE,
},
{ placeholderData: keepPreviousData }
)
);
const groups = groupsQuery.data?.data ?? [];
const types = typesQuery.data ?? [];
return (
<PageContainer>
<PageHeader
actions={
<Button onClick={() => pushModal('AddGroup')}>
<PlusIcon className="mr-2 size-4" />
Add group
</Button>
}
className="mb-8"
description="Groups represent companies, teams, or other entities that events belong to."
title="Groups"
/>
<div className="mb-6 flex flex-wrap gap-3">
<Input
className="w-64"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
value={search}
/>
{types.length > 0 && (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">No groups found</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-def-100">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Name
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
ID
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Type
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Created
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr
className="border-b transition-colors last:border-0 hover:bg-def-100"
key={`${group.projectId}-${group.id}`}
>
<td className="px-4 py-3">
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-muted-foreground text-xs">
{group.id}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{group.type}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDateTime(new Date(group.createdAt))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<GroupsTable
pageSize={PAGE_SIZE}
query={groupsQuery}
toolbarLeft={
types.length > 0 ? (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,46 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group events') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
groupId,
filters: [], // Always scope to group only; date + event names from toolbar still apply
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
}
)
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,208 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
const MEMBERS_PREVIEW_LIMIT = 13;
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return null;
}
const properties = g.properties as Record<string, unknown>;
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members preview */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<WidgetTable
columnClassName="px-2"
columns={[
{
key: 'profile',
name: 'Profile',
width: 'w-full',
render: (member) => (
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
),
},
{
key: 'events',
name: 'Events',
width: '60px',
className: 'text-muted-foreground',
render: (member) => member.eventCount,
},
{
key: 'lastSeen',
name: 'Last Seen',
width: '150px',
className: 'text-muted-foreground',
render: (member) =>
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
},
]}
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
keyExtractor={(member) => member.profileId}
/>
)}
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
<p className="border-t py-2 text-center text-muted-foreground text-xs">
{`${members.data.length} members found. View all in Members tab`}
</p>
)}
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group members') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const { page } = useDataTablePagination(50);
const query = useQuery({
...trpc.group.listProfiles.queryOptions({
projectId,
groupId,
cursor: page - 1,
take: 50,
search: debouncedSearch || undefined,
}),
placeholderData: keepPreviousData,
});
return (
<ProfilesTable
pageSize={50}
query={query as Parameters<typeof ProfilesTable>[0]['query']}
type="profiles"
/>
);
}

View File

@@ -0,0 +1,161 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import {
createFileRoute,
Outlet,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const router = useRouter();
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const navigate = useNavigate();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const deleteMutation = useMutation(
trpc.group.delete.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
navigate({
to: '/$organizationId/$projectId/groups',
params: { organizationId, projectId },
});
},
onError: handleError,
})
);
const { activeTab, tabs } = usePageTabs([
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
{ id: 'members', label: 'Members' },
{ id: 'events', label: 'Events' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
const g = group.data;
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
return (
<PageContainer className="col">
<PageHeader
actions={
<div className="row gap-2">
<Button
onClick={() =>
pushModal('EditGroup', {
id: g.id,
projectId: g.projectId,
name: g.name,
type: g.type,
properties: g.properties,
})
}
size="sm"
variant="outline"
>
<PencilIcon className="mr-2 size-4" />
Edit
</Button>
<Button
onClick={() =>
showConfirm({
title: 'Delete group',
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
onConfirm: () =>
deleteMutation.mutate({ id: g.id, projectId }),
})
}
size="sm"
variant="outline"
>
<Trash2Icon className="mr-2 size-4" />
Delete
</Button>
</div>
}
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
</div>
}
/>
<Tabs
className="mt-2 mb-8"
onValueChange={handleTabChange}
value={activeTab}
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -1,244 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon, UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { Badge } from '@/components/ui/badge';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.members.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
const properties = g.properties as Record<string, unknown>;
return (
<PageContainer>
<PageHeader
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
<Badge className="shrink-0" variant="outline">
{g.type}
</Badge>
</div>
}
>
<p className="font-mono text-muted-foreground text-sm">{g.id}</p>
</PageHeader>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody>
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left font-medium text-muted-foreground">
Profile
</th>
<th className="py-2 text-right font-medium text-muted-foreground">
Events
</th>
</tr>
</thead>
<tbody>
{members.data.map((member) => (
<tr
className="border-b last:border-0"
key={member.profileId}
>
<td className="py-2">
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
</td>
<td className="py-2 text-right text-muted-foreground">
{member.eventCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</WidgetBody>
</Widget>
</div>
</div>
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileGroups } from '@/components/profiles/profile-groups';
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react';
@@ -107,6 +108,17 @@ function Component() {
<ProfileProperties profile={profile.data!} />
</div>
{/* Groups - full width, only if profile belongs to groups */}
{profile.data?.groups?.length ? (
<div className="col-span-1 md:col-span-2">
<ProfileGroups
profileId={profileId}
projectId={projectId}
groups={profile.data.groups}
/>
</div>
) : null}
{/* Heatmap / Activity */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />