feat: group analytics

* wip

* wip

* wip

* wip

* wip

* add buffer

* wip

* wip

* fixes

* fix

* wip

* group validation

* fix group issues

* docs: add groups
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-20 10:46:09 +01:00
committed by GitHub
parent 88a2d876ce
commit 11e9ecac1a
99 changed files with 5944 additions and 1432 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,130 @@
import { TrendingUpIcon } from 'lucide-react';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody } from '@/components/widget';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
type Props = {
data: { date: string; count: number }[];
};
function Tooltip(props: any) {
const number = useNumber();
const formatDate = useFormatDateInterval({ interval: 'day', short: false });
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-muted-foreground text-sm">
{formatDate(new Date(payload.timestamp))}
</div>
<div className="flex items-center gap-2">
<div
className="h-10 w-1 rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col gap-1">
<div className="text-muted-foreground text-sm">Total members</div>
<div
className="font-semibold text-lg"
style={{ color: getChartColor(0) }}
>
{number.format(payload.cumulative)}
</div>
</div>
</div>
{payload.count > 0 && (
<div className="text-muted-foreground text-xs">
+{number.format(payload.count)} new
</div>
)}
</div>
);
}
export function GroupMemberGrowth({ data }: Props) {
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps({});
const color = getChartColor(0);
let cumulative = 0;
const chartData = data.map((item) => {
cumulative += item.count;
return {
date: item.date,
timestamp: new Date(item.date).getTime(),
count: item.count,
cumulative,
};
});
const gradientId = 'memberGrowthGradient';
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
</WidgetHead>
<WidgetBody>
{data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No data yet
</p>
) : (
<div className="h-[200px] w-full">
<ResponsiveContainer>
<AreaChart data={chartData}>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<RechartTooltip
content={<Tooltip />}
cursor={{ stroke: color, strokeOpacity: 0.3 }}
/>
<Area
dataKey="cumulative"
dot={false}
fill={`url(#${gradientId})`}
isAnimationActive={false}
stroke={color}
strokeWidth={2}
type="monotone"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<CartesianGrid
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
strokeOpacity={0.5}
vertical={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,76 @@
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 type IServiceGroupWithStats = IServiceGroup & {
memberCount: number;
lastActiveAt: Date | null;
};
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
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: 'memberCount',
header: 'Members',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.memberCount}</span>
),
},
{
accessorKey: 'lastActiveAt',
header: 'Last active',
size: ColumnCreatedAt.size,
cell: ({ row }) =>
row.original.lastActiveAt ? (
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
) : (
<span className="text-muted-foreground"></span>
),
},
{
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 { type IServiceGroupWithStats, 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 IServiceGroupWithStats[];
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.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<IServiceGroupWithStats>;
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

@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
<XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
strokeOpacity={0.5}

View File

@@ -1,4 +1,5 @@
import { Widget } from '@/components/widget';
import { ZapIcon } from 'lucide-react';
import { Widget, WidgetEmptyState } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = {
@@ -6,28 +7,32 @@ type Props = {
};
export const MostEvents = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Popular events</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
{data.length === 0 ? (
<WidgetEmptyState icon={ZapIcon} text="No events yet" />
) : (
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</Widget>
);
};

View File

@@ -1,4 +1,5 @@
import { Widget } from '@/components/widget';
import { RouteIcon } from 'lucide-react';
import { Widget, WidgetEmptyState } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = {
@@ -6,28 +7,32 @@ type Props = {
};
export const PopularRoutes = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
{data.length === 0 ? (
<WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
) : (
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</Widget>
);
};

View File

@@ -0,0 +1,43 @@
import { ProjectLink } from '@/components/links';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react';
interface 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 === 0 || !query.data?.length) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
<UsersIcon className="size-3.5" />
Groups
</span>
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
>
<span className="font-medium">{group.name}</span>
<span className="text-muted-foreground">{group.type}</span>
</ProjectLink>
))}
</div>
);
};

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

@@ -1,5 +1,8 @@
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import {
ActivityIcon,
Building2Icon,
ClockIcon,
EqualApproximatelyIcon,
type LucideIcon,
@@ -10,10 +13,7 @@ import {
UserCheckIcon,
UsersIcon,
} from 'lucide-react';
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,7 +25,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
interface ReportChartTypeProps {
className?: string;
@@ -46,6 +45,7 @@ export function ReportSegment({
event: ActivityIcon,
user: UsersIcon,
session: ClockIcon,
group: Building2Icon,
user_average: UserCheck2Icon,
one_event_per_user: UserCheckIcon,
property_sum: SigmaIcon,
@@ -58,9 +58,9 @@ export function ReportSegment({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start text-sm', className)}
icon={Icons[value]}
variant="outline"
>
{items.find((item) => item.value === value)?.label}
</Button>
@@ -74,13 +74,13 @@ export function ReportSegment({
const Icon = Icons[item.value];
return (
<DropdownMenuItem
className="group"
key={item.value}
onClick={() => onChange(item.value)}
className="group"
>
{item.label}
<DropdownMenuShortcut>
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
<Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuShortcut>
</DropdownMenuItem>
);

View File

@@ -1,3 +1,14 @@
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowLeftIcon,
Building2Icon,
DatabaseIcon,
UserIcon,
} from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -10,11 +21,7 @@ import {
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties';
import type { IChartEvent } from '@openpanel/validation';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
interface PropertiesComboboxProps {
event?: IChartEvent;
@@ -40,15 +47,15 @@ function SearchHeader({
return (
<div className="row items-center gap-1">
{!!onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<Button onClick={onBack} size="icon" variant="ghost">
<ArrowLeftIcon className="size-4" />
</Button>
)}
<Input
autoFocus
onChange={(e) => onSearch(e.target.value)}
placeholder="Search"
value={value}
onChange={(e) => onSearch(e.target.value)}
autoFocus
/>
</div>
);
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
exclude = [],
}: PropertiesComboboxProps) {
const { projectId } = useAppParams();
const trpc = useTRPC();
const [open, setOpen] = useState(false);
const properties = useEventProperties({
event: event?.name,
projectId,
});
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
const groupPropertiesQuery = useQuery(
trpc.group.properties.queryOptions({ projectId })
);
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
'index'
);
const [search, setSearch] = useState('');
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
useEffect(() => {
if (!open) {
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
}
}, [open, mode]);
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
});
};
// Mock data for the lists
// Fixed group properties: name, type, plus dynamic property keys
const groupActions = [
{ value: 'group.name', label: 'name', description: 'group' },
{ value: 'group.type', label: 'type', description: 'group' },
...(groupPropertiesQuery.data ?? []).map((key) => ({
value: `group.properties.${key}`,
label: key,
description: 'group.properties',
})),
].filter((a) => shouldShowProperty(a.value));
const profileActions = properties
.filter(
(property) =>
property.startsWith('profile') && shouldShowProperty(property),
property.startsWith('profile') && shouldShowProperty(property)
)
.map((property) => ({
value: property,
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
const eventActions = properties
.filter(
(property) =>
!property.startsWith('profile') && shouldShowProperty(property),
!property.startsWith('profile') && shouldShowProperty(property)
)
.map((property) => ({
value: property,
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
description: property.split('.').slice(0, -1).join('.'),
}));
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
const handleStateChange = (
newState: 'index' | 'event' | 'profile' | 'group'
) => {
setDirection(newState === 'index' ? 'backward' : 'forward');
setState(newState);
};
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
}}
>
Event properties
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
<DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem>
<DropdownMenuItem
className="group justify-between gap-2"
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
}}
>
Profile properties
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
<UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem>
<DropdownMenuItem
className="group justify-between gap-2"
onClick={(e) => {
e.preventDefault();
handleStateChange('group');
}}
>
Group properties
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem>
</DropdownMenuGroup>
);
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
const filteredActions = eventActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()),
action.description.toLowerCase().includes(search.toLowerCase())
);
return (
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
/>
<DropdownMenuSeparator />
<VirtualList
height={300}
data={filteredActions}
height={300}
itemHeight={40}
itemKey="id"
>
{(action) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{action.description}
</div>
</motion.div>
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
const filteredActions = profileActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()),
action.description.toLowerCase().includes(search.toLowerCase())
);
return (
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
/>
<DropdownMenuSeparator />
<VirtualList
height={300}
data={filteredActions}
height={300}
itemHeight={40}
itemKey="id"
>
{(action) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{action.description}
</div>
</motion.div>
)}
</VirtualList>
</div>
);
};
const renderGroup = () => {
const filteredActions = groupActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col">
<SearchHeader
onBack={() => handleStateChange('index')}
onSearch={setSearch}
value={search}
/>
<DropdownMenuSeparator />
<VirtualList
data={filteredActions}
height={Math.min(300, filteredActions.length * 40 + 8)}
itemHeight={40}
itemKey="value"
>
{(action) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-muted-foreground text-sm">
{action.description}
</div>
</motion.div>
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
return (
<DropdownMenu
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
open={open}
>
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-80" align="start">
<AnimatePresence mode="wait" initial={false}>
<DropdownMenuContent align="start" className="max-w-80">
<AnimatePresence initial={false} mode="wait">
{state === 'index' && (
<motion.div
key="index"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="index"
transition={{ duration: 0.05 }}
>
{renderIndex()}
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
)}
{state === 'event' && (
<motion.div
key="event"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="event"
transition={{ duration: 0.05 }}
>
{renderEvent()}
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
)}
{state === 'profile' && (
<motion.div
key="profile"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="profile"
transition={{ duration: 0.05 }}
>
{renderProfile()}
</motion.div>
)}
{state === 'group' && (
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="group"
transition={{ duration: 0.05 }}
>
{renderGroup()}
</motion.div>
)}
</AnimatePresence>
</DropdownMenuContent>
</DropdownMenu>

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

@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import {
BellIcon,
BookOpenIcon,
Building2Icon,
ChartLineIcon,
ChevronDownIcon,
CogIcon,
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage
</div>

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

@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>;
}
export interface WidgetEmptyStateProps {
icon: LucideIcon;
text: string;
}
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<Icon size={28} strokeWidth={1.5} />
<p className="text-sm">{text}</p>
</div>
);
}
export interface WidgetProps {
children: React.ReactNode;
className?: string;