diff --git a/apps/start/src/components/chat/tool-results/chat-report-result.tsx b/apps/start/src/components/chat/tool-results/chat-report-result.tsx index 8d119f70..02d75bc1 100644 --- a/apps/start/src/components/chat/tool-results/chat-report-result.tsx +++ b/apps/start/src/components/chat/tool-results/chat-report-result.tsx @@ -180,6 +180,8 @@ function humanizeChartType(type: string): string { return 'Funnel'; case 'metric': return 'Metric'; + case 'table': + return 'Table'; case 'retention': return 'Retention'; case 'histogram': diff --git a/apps/start/src/components/report-chart/common/report-table-toolbar.tsx b/apps/start/src/components/report-chart/common/report-table-toolbar.tsx index 00536876..99f376a3 100644 --- a/apps/start/src/components/report-chart/common/report-table-toolbar.tsx +++ b/apps/start/src/components/report-chart/common/report-table-toolbar.tsx @@ -1,6 +1,18 @@ import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { List, Rows3, Search, X } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Switch } from '@/components/ui/switch'; +import { Columns3, List, Rows3, Search, X } from 'lucide-react'; + +export type ReportTableAliasableColumn = { + key: string; + label: string; +}; interface ReportTableToolbarProps { grouped?: boolean; @@ -8,6 +20,13 @@ interface ReportTableToolbarProps { search: string; onSearchChange?: (value: string) => void; onUnselectAll?: () => void; + aliasableColumns?: ReportTableAliasableColumn[]; + columnAliases?: Record; + hiddenColumnKeys?: string[]; + dateMode?: 'columns' | 'aggregate'; + onColumnAliasChange?: (key: string, alias: string) => void; + onColumnVisibilityChange?: (key: string, visible: boolean) => void; + onDateModeChange?: (dateMode: 'columns' | 'aggregate') => void; } export function ReportTableToolbar({ @@ -16,7 +35,18 @@ export function ReportTableToolbar({ search, onSearchChange, onUnselectAll, + aliasableColumns = [], + columnAliases = {}, + hiddenColumnKeys = [], + dateMode = 'columns', + onColumnAliasChange, + onColumnVisibilityChange, + onDateModeChange, }: ReportTableToolbarProps) { + const showColumnControls = + (!!onColumnAliasChange || !!onColumnVisibilityChange || !!onDateModeChange) && + aliasableColumns.length > 0; + return (
{onSearchChange && ( @@ -31,6 +61,73 @@ export function ReportTableToolbar({
)}
+ {showColumnControls && ( + + + + + +
+
Table columns
+

+ Show, hide, and rename columns for this table chart only. +

+
+ {onDateModeChange && ( +
+
+
+ Aggregate date columns +
+

+ Show one total column instead of one column per interval. +

+
+ + onDateModeChange(checked ? 'aggregate' : 'columns') + } + /> +
+ )} +
+
+ {aliasableColumns.map((column) => ( +
+ {onColumnVisibilityChange && ( + + onColumnVisibilityChange(column.key, checked === true) + } + className="mt-6 h-4 w-4 shrink-0" + /> + )} + +
+ ))} +
+
+
+
+ )} {onToggleGrouped && (
); - }, - }); + }, + }); + } // Breakdown columns (pinned left, collapsible) breakdownPropertyNames.forEach((propertyName, index) => { const isLastBreakdown = index === breakdownPropertyNames.length - 1; const isCollapsible = grouped && !isLastBreakdown; + const aliasKey = getBreakdownColumnKey( + breakdownPropertyKeys, + propertyName, + index, + ); + + if (!isColumnVisible(aliasKey)) { + return; + } cols.push({ id: `breakdown-${index}`, @@ -789,7 +881,7 @@ export function ReportTable({ }, header: ({ column }) => { if (!isCollapsible) { - return propertyName; + return getColumnAlias(aliasKey, propertyName); } // Find all rows at this breakdown level that can be expanded @@ -862,7 +954,7 @@ export function ReportTable({ role="button" tabIndex={0} > - {propertyName} + {getColumnAlias(aliasKey, propertyName)} ); }, @@ -928,18 +1020,14 @@ export function ReportTable({ }); // Metric columns - const metrics = [ - { key: 'count', label: 'Unique' }, - { key: 'sum', label: 'Sum' }, - { key: 'average', label: 'Average' }, - { key: 'min', label: 'Min' }, - { key: 'max', label: 'Max' }, - ] as const; - - metrics.forEach((metric) => { + METRIC_COLUMNS.forEach((metric) => { + if (!isColumnVisible(`metric:${metric.key}`)) { + return; + } + cols.push({ id: `metric-${metric.key}`, - header: metric.label, + header: getColumnAlias(`metric:${metric.key}`, metric.label), accessorKey: metric.key, enableSorting: true, size: 100, @@ -983,10 +1071,61 @@ export function ReportTable({ }); // Date columns + if (dateMode === 'aggregate') { + if (isColumnVisible('date:total')) { + cols.push({ + id: 'date-total', + header: getColumnAlias('date:total', 'Total'), + accessorFn: getDateTotal, + enableSorting: true, + size: 120, + cell: ({ row }) => { + const value = getDateTotal(row.original); + const isSummary = row.original.isSummaryRow ?? false; + const isGroupHeader = + 'isGroupHeader' in row.original && + row.original.isGroupHeader === true; + const isIndividualRow = !isSummary && !isGroupHeader; + const hasValidRange = + dateTotalRange.min !== Number.POSITIVE_INFINITY && + dateTotalRange.max !== Number.NEGATIVE_INFINITY; + const backgroundStyle = isIndividualRow && hasValidRange + ? getCellBackgroundStyle( + value, + dateTotalRange.min, + dateTotalRange.max, + 'emerald', + ).style + : {}; + + return ( +
+ {number.format(value)} +
+ ); + }, + }); + } + + return cols; + } + dates.forEach((date) => { + const formattedDate = formatDate(date); + if (!isColumnVisible(`date:${date}`)) { + return; + } + cols.push({ id: `date-${date}`, - header: formatDate(date), + header: getColumnAlias(`date:${date}`, formattedDate), accessorFn: (row) => row.dateValues[date] ?? 0, enableSorting: true, size: 100, @@ -1030,6 +1169,7 @@ export function ReportTable({ return cols; }, [ breakdownPropertyNames, + breakdownPropertyKeys, dates, formatDate, number, @@ -1039,9 +1179,13 @@ export function ReportTable({ rows, metricRanges, dateRanges, + dateTotalRange, + dateMode, + hiddenColumnKeys, columnSizing, expanded, data, + columnAliases, ]); // Create a hash of column IDs to track when columns change @@ -1288,7 +1432,12 @@ export function ReportTable({ } return ( -
+
setVisibleSeries([])} + aliasableColumns={aliasableColumns} + columnAliases={columnAliases} + hiddenColumnKeys={hiddenColumnKeys} + dateMode={dateMode} + onColumnAliasChange={onColumnAliasChange} + onColumnVisibilityChange={onColumnVisibilityChange} + onDateModeChange={onDateModeChange} />
{ const ref = useRef(null); @@ -52,6 +53,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => { return ; case 'metric': return ; + case 'table': + return ; case 'funnel': return ; case 'retention': diff --git a/apps/start/src/components/report-chart/table/index.tsx b/apps/start/src/components/report-chart/table/index.tsx new file mode 100644 index 00000000..a7b38b2e --- /dev/null +++ b/apps/start/src/components/report-chart/table/index.tsx @@ -0,0 +1,124 @@ +import { + changeTableColumnAlias, + changeTableColumnVisibility, + changeTableDateMode, + changeVisibleSeries, +} from '@/components/report/reportSlice'; +import { useVisibleSeries } from '@/hooks/use-visible-series'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useDispatch } from '@/redux'; +import type { IChartData } from '@/trpc/client'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { ReportChartEmpty } from '../common/empty'; +import { ReportChartError } from '../common/error'; +import { ReportChartLoading } from '../common/loading'; +import { ReportTable } from '../common/report-table'; +import { useChartInput, useReportChartContext } from '../context'; + +export function ReportTableChart() { + const { isLazyLoading, isEditMode, report, shareId } = + useReportChartContext(); + const chartInput = useChartInput(); + const dispatch = useDispatch(); + const trpc = useTRPC(); + + const res = useQuery( + trpc.chart.chart.queryOptions( + { + ...chartInput, + shareId, + }, + { + placeholderData: keepPreviousData, + enabled: !isLazyLoading, + }, + ), + ); + + if ( + isLazyLoading || + res.isLoading || + (res.isFetching && !res.data?.series.length) + ) { + return ; + } + + if (res.isError) { + return ; + } + + if (!res.data || res.data.series.length === 0) { + return ; + } + + const tableOptions = report.options?.type === 'table' ? report.options : null; + + return ( + + dispatch(changeTableColumnAlias({ key, alias })) + } + onColumnVisibilityChange={(key, visible) => + dispatch(changeTableColumnVisibility({ key, visible })) + } + onDateModeChange={(dateMode) => dispatch(changeTableDateMode(dateMode))} + onVisibleSeriesChange={(ids) => dispatch(changeVisibleSeries(ids))} + /> + ); +} + +function Table({ + data, + isEditMode, + columnAliases, + hiddenColumnKeys, + dateMode, + savedVisibleSeries, + onColumnAliasChange, + onColumnVisibilityChange, + onDateModeChange, + onVisibleSeriesChange, +}: { + data: IChartData; + isEditMode: boolean; + columnAliases: Record; + hiddenColumnKeys: string[]; + dateMode: 'columns' | 'aggregate'; + savedVisibleSeries?: string[] | null; + onColumnAliasChange: (key: string, alias: string) => void; + onColumnVisibilityChange: (key: string, visible: boolean) => void; + onDateModeChange: (dateMode: 'columns' | 'aggregate') => void; + onVisibleSeriesChange: (ids: string[]) => void; +}) { + const { series, setVisibleSeries } = useVisibleSeries(data, { + limit: data.series.length, + savedVisibleSeries, + onVisibleSeriesChange: isEditMode ? onVisibleSeriesChange : undefined, + }); + + return ( + + ); +} + + diff --git a/apps/start/src/components/report/ReportChartType.tsx b/apps/start/src/components/report/ReportChartType.tsx index 4d6129e6..026a5f06 100644 --- a/apps/start/src/components/report/ReportChartType.tsx +++ b/apps/start/src/components/report/ReportChartType.tsx @@ -10,6 +10,7 @@ import { LineChartIcon, type LucideIcon, PieChartIcon, + TableIcon, TrendingUpIcon, UsersIcon, } from 'lucide-react'; @@ -56,6 +57,7 @@ export function ReportChartType({ histogram: ChartColumnIncreasingIcon, linear: LineChartIcon, metric: GaugeIcon, + table: TableIcon, retention: UsersIcon, map: Globe2Icon, conversion: TrendingUpIcon, diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts index a6ffddd9..62304c30 100644 --- a/apps/start/src/components/report/reportSlice.ts +++ b/apps/start/src/components/report/reportSlice.ts @@ -195,6 +195,15 @@ export const reportSlice = createSlice({ }; } + if (action.payload === 'table' && state.options?.type !== 'table') { + state.options = { + type: 'table', + columnAliases: {}, + hiddenColumns: [], + dateMode: 'columns', + }; + } + if ( !isMinuteIntervalEnabledByRange(state.range) && state.interval === 'minute' @@ -306,6 +315,63 @@ export const reportSlice = createSlice({ state.dirty = true; state.options = action.payload || undefined; }, + changeTableColumnAlias( + state, + action: PayloadAction<{ key: string; alias: string }>, + ) { + state.dirty = true; + if (!state.options || state.options.type !== 'table') { + state.options = { + type: 'table', + columnAliases: {}, + hiddenColumns: [], + dateMode: 'columns', + }; + } + + const alias = action.payload.alias.trim(); + if (alias) { + state.options.columnAliases[action.payload.key] = alias; + } else { + delete state.options.columnAliases[action.payload.key]; + } + }, + changeTableColumnVisibility( + state, + action: PayloadAction<{ key: string; visible: boolean }>, + ) { + state.dirty = true; + if (!state.options || state.options.type !== 'table') { + state.options = { + type: 'table', + columnAliases: {}, + hiddenColumns: [], + dateMode: 'columns', + }; + } + + const hiddenColumns = state.options.hiddenColumns ?? []; + if (action.payload.visible) { + state.options.hiddenColumns = hiddenColumns.filter( + (key) => key !== action.payload.key, + ); + } else if (!hiddenColumns.includes(action.payload.key)) { + state.options.hiddenColumns = [...hiddenColumns, action.payload.key]; + } + }, + changeTableDateMode(state, action: PayloadAction<'columns' | 'aggregate'>) { + state.dirty = true; + if (!state.options || state.options.type !== 'table') { + state.options = { + type: 'table', + columnAliases: {}, + hiddenColumns: [], + dateMode: action.payload, + }; + } else { + state.options.dateMode = action.payload; + } + }, changeSankeyMode( state, action: PayloadAction<'between' | 'after' | 'before'>, @@ -421,6 +487,9 @@ export const { changeFunnelGroup, changeFunnelWindow, changeOptions, + changeTableColumnAlias, + changeTableColumnVisibility, + changeTableDateMode, changeSankeyMode, changeSankeySteps, changeSankeyExclude, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx index ec217388..45fe4cf1 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx @@ -20,6 +20,7 @@ import { Pencil, PieChartIcon, PlusIcon, + TableIcon, Trash, TrendingUpIcon, } from 'lucide-react'; @@ -150,6 +151,7 @@ function Component() { linear: LineChartIcon, pie: PieChartIcon, metric: HashIcon, + table: TableIcon, map: Globe2Icon, histogram: BarChart3Icon, funnel: ConeIcon, diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 91129751..537a3936 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -103,6 +103,7 @@ export const chartTypes = { histogram: 'Histogram', pie: 'Pie', metric: 'Metric', + table: 'Table', area: 'Area', map: 'Map', funnel: 'Funnel', diff --git a/packages/db/prisma/migrations/20260508120000_add_table_chart_type/migration.sql b/packages/db/prisma/migrations/20260508120000_add_table_chart_type/migration.sql new file mode 100644 index 00000000..e38da979 --- /dev/null +++ b/packages/db/prisma/migrations/20260508120000_add_table_chart_type/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "ChartType" ADD VALUE 'table'; + diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 707df5fc..07f0513c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -348,6 +348,7 @@ enum ChartType { histogram pie metric + table area map funnel diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 18fc4b9f..a2bf44fb 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -141,16 +141,25 @@ export const zHistogramOptions = z.object({ stacked: z.boolean().default(false), }); +export const zTableOptions = z.object({ + type: z.literal('table'), + columnAliases: z.record(z.string(), z.string()).default({}), + hiddenColumns: z.array(z.string()).default([]), + dateMode: z.enum(['columns', 'aggregate']).default('columns'), +}); + export const zReportOptions = z.discriminatedUnion('type', [ zFunnelOptions, zRetentionOptions, zSankeyOptions, zHistogramOptions, + zTableOptions, ]); export type IReportOptions = z.infer; export type ISankeyOptions = z.infer; export type IHistogramOptions = z.infer; +export type ITableOptions = z.infer; export const zWidgetType = z.enum(['realtime', 'counter']); export type IWidgetType = z.infer;