a lot
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useChartContext } from './chart/ChartProvider';
|
||||
|
||||
interface PreviousDiffIndicatorProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
children?: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
@@ -15,25 +17,38 @@ export function PreviousDiffIndicator({
|
||||
state,
|
||||
children,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const { previous } = useChartContext();
|
||||
const { previous, previousIndicatorInverted } = useChartContext();
|
||||
const number = useNumber();
|
||||
if (diff === null || diff === undefined || previous === false) {
|
||||
return children ?? null;
|
||||
}
|
||||
|
||||
if (previousIndicatorInverted === true) {
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
className="flex gap-1"
|
||||
variant={state === 'positive' ? 'destructive' : 'success'}
|
||||
>
|
||||
{state === 'negative' && <TrendingUpIcon size={15} />}
|
||||
{state === 'positive' && <TrendingDownIcon size={15} />}
|
||||
{number.format(diff)}%
|
||||
</Badge>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('flex items-center', [
|
||||
state === 'positive' && 'text-emerald-500',
|
||||
state === 'negative' && 'text-rose-500',
|
||||
state === 'neutral' && 'text-slate-400',
|
||||
])}
|
||||
<Badge
|
||||
className="flex gap-1"
|
||||
variant={state === 'positive' ? 'success' : 'destructive'}
|
||||
>
|
||||
{state === 'positive' && <ChevronUp size={20} />}
|
||||
{state === 'negative' && <ChevronDown size={20} />}
|
||||
{state === 'positive' && <TrendingUpIcon size={15} />}
|
||||
{state === 'negative' && <TrendingDownIcon size={15} />}
|
||||
{number.format(diff)}%
|
||||
</div>
|
||||
</Badge>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import { changeDateRanges } from './reportSlice';
|
||||
|
||||
export function ReportDateRange() {
|
||||
const dispatch = useDispatch();
|
||||
const range = useSelector((state) => state.report.range);
|
||||
|
||||
return (
|
||||
<RadioGroup className="overflow-auto">
|
||||
{Object.values(timeRanges).map((key) => {
|
||||
return (
|
||||
<RadioGroupItem
|
||||
key={key}
|
||||
active={key === range}
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(key));
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</RadioGroupItem>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/report/ReportRange.tsx
Normal file
20
apps/web/src/components/report/ReportRange.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IChartRange } from '@/types';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
import type { ExtendedComboboxProps } from '../ui/combobox';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
|
||||
export function ReportRange(props: ExtendedComboboxProps<IChartRange>) {
|
||||
return (
|
||||
<Combobox
|
||||
icon={CalendarIcon}
|
||||
placeholder={'Range'}
|
||||
items={Object.values(timeRanges).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
export interface ChartContextType {
|
||||
editMode: boolean;
|
||||
previous?: boolean;
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: any) => void;
|
||||
}
|
||||
|
||||
type ChartProviderProps = {
|
||||
children: React.ReactNode;
|
||||
} & ChartContextType;
|
||||
|
||||
const ChartContext = createContext<ChartContextType>({
|
||||
editMode: false,
|
||||
const ChartContext = createContext<ChartContextType | null>({
|
||||
events: [],
|
||||
breakdowns: [],
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: '',
|
||||
range: '7d',
|
||||
metric: 'sum',
|
||||
previous: false,
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
export function ChartProvider({
|
||||
children,
|
||||
editMode,
|
||||
previous,
|
||||
hideID,
|
||||
...props
|
||||
}: ChartProviderProps) {
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
editMode,
|
||||
editMode: editMode ?? false,
|
||||
previous: previous ?? false,
|
||||
hideID: hideID ?? false,
|
||||
...props,
|
||||
}),
|
||||
[editMode, previous]
|
||||
[editMode, previous, hideID, props]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -52,5 +67,5 @@ export function withChartProivder<ComponentProps>(
|
||||
}
|
||||
|
||||
export function useChartContext() {
|
||||
return useContext(ChartContext);
|
||||
return useContext(ChartContext)!;
|
||||
}
|
||||
|
||||
81
apps/web/src/components/report/chart/MetricCard.tsx
Normal file
81
apps/web/src/components/report/chart/MetricCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IChartMetric } from '@/types';
|
||||
import { theme } from '@/utils/theme';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
|
||||
interface MetricCardProps {
|
||||
serie: IChartData['series'][number];
|
||||
color?: string;
|
||||
metric: IChartMetric;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
serie,
|
||||
color: _color,
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const color = _color || theme?.colors['chart-0'];
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div
|
||||
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
||||
key={serie.name}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 3}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 3) * 2 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill="url(#area)"
|
||||
fillOpacity={1}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
||||
</div>
|
||||
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
||||
</div>
|
||||
<div className="flex justify-between items-end mt-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{number.format(serie.metrics[metric])}
|
||||
{unit && <span className="ml-1 font-light text-xl">{unit}</span>}
|
||||
</div>
|
||||
{!!serie.metrics.previous[metric] && (
|
||||
<div>
|
||||
{number.format(serie.metrics.previous[metric]?.value)}
|
||||
{unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function ReportAreaChart({
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
@@ -21,7 +26,6 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useElementSize } from 'usehooks-ts';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
@@ -34,10 +38,11 @@ interface ReportBarChartProps {
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [ref, { width }] = useElementSize();
|
||||
const { editMode, metric, unit, onClick } = useChartContext();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
|
||||
const maxCount = Math.max(
|
||||
...data.series.map((serie) => serie.metrics[metric])
|
||||
);
|
||||
const number = useNumber();
|
||||
const table = useReactTable({
|
||||
data: useMemo(
|
||||
@@ -53,46 +58,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorSquare>{info.row.original.event.id}</ColorSquare>
|
||||
{info.getValue()}
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-ellipsis overflow-hidden">
|
||||
{info.getValue()}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{info.getValue()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.3 : undefined,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
columnHelper.accessor((row) => row.metrics[metric], {
|
||||
id: 'totalCount',
|
||||
cell: (info) => (
|
||||
<div className="text-right font-medium flex gap-2">
|
||||
<div>{number.format(info.getValue())}</div>
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className="top-0 absolute shine h-[20px] rounded-full"
|
||||
style={{
|
||||
width: (info.getValue() / maxCount) * 100 + '%',
|
||||
background: getChartColor(info.row.index),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
{number.format(info.getValue())}
|
||||
{unit}
|
||||
</div>
|
||||
<PreviousDiffIndicator
|
||||
{...info.row.original.metrics.previous.sum}
|
||||
{...info.row.original.metrics.previous[metric]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
header: () => 'Count',
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.1 : undefined,
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
id: 'graph',
|
||||
cell: (info) => (
|
||||
<div
|
||||
className="shine h-4 rounded [.mini_&]:h-3"
|
||||
style={{
|
||||
width: (info.getValue() / maxCount) * 100 + '%',
|
||||
background: getChartColor(info.row.index),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
header: () => 'Graph',
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.6 : undefined,
|
||||
}),
|
||||
];
|
||||
}, [width]),
|
||||
columnResizeMode: 'onChange',
|
||||
}, [maxCount, number]),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
@@ -102,85 +106,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
{...{
|
||||
className: editMode ? '' : 'mini',
|
||||
style: {
|
||||
width: table.getTotalSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
{...{
|
||||
colSpan: header.colSpan,
|
||||
style: {
|
||||
width: header.getSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
className: cn(
|
||||
'flex items-center gap-2',
|
||||
header.column.getCanSort() &&
|
||||
'cursor-pointer select-none'
|
||||
),
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
<div
|
||||
{...(editMode
|
||||
? {
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
className: `resizer ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`,
|
||||
style: {},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
<Table
|
||||
overflow={editMode}
|
||||
className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
{...{
|
||||
colSpan: header.colSpan,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
className: cn(
|
||||
'flex items-center gap-2',
|
||||
header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
),
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
{...{
|
||||
style: {
|
||||
width: cell.column.getSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
{...(onClick
|
||||
? {
|
||||
onClick() {
|
||||
onClick(row.original);
|
||||
},
|
||||
className: 'cursor-pointer',
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { previous } = useChartContext();
|
||||
const { previous, unit } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
@@ -41,7 +41,7 @@ export function ReportChartTooltip({
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
@@ -57,11 +57,11 @@ export function ReportChartTooltip({
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
{previous && data.previous?.date && (
|
||||
{/* {previous && data.previous?.date && (
|
||||
<div className="text-slate-400 italic">
|
||||
{formatDate(new Date(data.previous.date))}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
@@ -74,11 +74,15 @@ export function ReportChartTooltip({
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{number.format(data.count)}</div>
|
||||
<div>
|
||||
{number.format(data.count)}
|
||||
{unit}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous && `(${data.previous.count})`}
|
||||
{!!data.previous &&
|
||||
`(${data.previous.value + (unit ? unit : '')})`}
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ReportHistogramChart({
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
@@ -35,7 +35,7 @@ export function ReportLineChart({
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
40
apps/web/src/components/report/chart/ReportMapChart.tsx
Normal file
40
apps/web/src/components/report/chart/ReportMapChart.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { theme } from '@/utils/theme';
|
||||
import WorldMap from 'react-svg-worldmap';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface ReportMapChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMapChart({ data }: ReportMapChartProps) {
|
||||
const { metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, 100);
|
||||
|
||||
const mapData = useMemo(
|
||||
() =>
|
||||
series.map((s) => ({
|
||||
country: s.name.toLowerCase(),
|
||||
value: s.metrics[metric],
|
||||
})),
|
||||
[series, metric]
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<WorldMap
|
||||
size={width}
|
||||
data={mapData}
|
||||
color={theme.colors['chart-0']}
|
||||
borderColor={'#103A96'}
|
||||
value-suffix={unit}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { theme } from '@/utils/theme';
|
||||
import { ChevronDown, ChevronUp, ChevronUpCircle } from 'lucide-react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { editMode, metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
|
||||
const color = theme?.colors['chart-0'];
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -29,62 +21,12 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<div
|
||||
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
||||
<MetricCard
|
||||
key={serie.name}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 3}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 3) * 2 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill="url(#area)"
|
||||
fillOpacity={1}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="mt-6 font-mono text-3xl font-bold">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
{!!serie.metrics.previous.sum && (
|
||||
<div className="flex flex-col items-end">
|
||||
<PreviousDiffIndicator {...serie.metrics.previous.sum}>
|
||||
<div className="font-mono">
|
||||
{number.format(serie.metrics.previous.sum.value)}
|
||||
</div>
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
serie={serie}
|
||||
metric={metric}
|
||||
unit={unit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
@@ -15,66 +15,19 @@ interface ReportPieChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderLabel = ({
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
payload,
|
||||
...props
|
||||
}: any) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xx = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const yy = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const label = payload.label;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xx}
|
||||
y={yy}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="black"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
||||
// Get max 10 series and than combine others into one
|
||||
const pieData = series.map((serie) => {
|
||||
return {
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
};
|
||||
});
|
||||
const pieData = series.map((serie) => ({
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,3 +80,58 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
fill,
|
||||
payload,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
fill: string;
|
||||
payload: { label: string; percent: number };
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = 25 + innerRadius + (outerRadius - innerRadius);
|
||||
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
|
||||
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const label = payload.label;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xProcent}
|
||||
y={yProcent}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
pointerEvents={'none'}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={fill}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
>
|
||||
{truncate(label, 20)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
@@ -11,10 +12,13 @@ import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
import { ReportMapChart } from './ReportMapChart';
|
||||
import { ReportMetricChart } from './ReportMetricChart';
|
||||
import { ReportPieChart } from './ReportPieChart';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
export type ReportChartProps = IChartInput & {
|
||||
initialData?: RouterOutputs['chart']['chart'];
|
||||
};
|
||||
|
||||
export const Chart = memo(
|
||||
withChartProivder(function Chart({
|
||||
@@ -26,18 +30,23 @@ export const Chart = memo(
|
||||
range,
|
||||
lineType,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
initialData,
|
||||
}: ReportChartProps) {
|
||||
const params = useAppParams();
|
||||
const hasEmptyFilters = events.some((event) =>
|
||||
event.filters.some((filter) => filter.value.length === 0)
|
||||
);
|
||||
const enabled = events.length > 0 && !hasEmptyFilters;
|
||||
|
||||
const chart = api.chart.chart.useQuery(
|
||||
{
|
||||
interval,
|
||||
chartType,
|
||||
// dont send lineType since it does not need to be sent
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
chartType,
|
||||
events,
|
||||
breakdowns,
|
||||
name,
|
||||
@@ -46,10 +55,14 @@ export const Chart = memo(
|
||||
endDate: null,
|
||||
projectId: params.projectId,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
},
|
||||
{
|
||||
keepPreviousData: false,
|
||||
keepPreviousData: true,
|
||||
enabled,
|
||||
initialData,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,10 +79,10 @@ export const Chart = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isFetching) {
|
||||
if (chart.isLoading) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" />
|
||||
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
|
||||
<p className="text-center font-medium">Loading...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
@@ -99,6 +112,10 @@ export const Chart = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'map') {
|
||||
return <ReportMapChart data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart interval={interval} data={chart.data} />;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type InitialState = IChartInput & {
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
// TODO: remove this
|
||||
projectId: '',
|
||||
name: 'Untitled',
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
@@ -37,6 +39,9 @@ const initialState: InitialState = {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
previous: false,
|
||||
formula: undefined,
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -100,6 +105,12 @@ export const reportSlice = createSlice({
|
||||
});
|
||||
},
|
||||
|
||||
// Previous
|
||||
changePrevious: (state, action: PayloadAction<boolean>) => {
|
||||
state.dirty = true;
|
||||
state.previous = action.payload;
|
||||
},
|
||||
|
||||
// Breakdowns
|
||||
addBreakdown: (
|
||||
state,
|
||||
@@ -181,6 +192,12 @@ export const reportSlice = createSlice({
|
||||
state.range = action.payload;
|
||||
state.interval = getDefaultIntervalByRange(action.payload);
|
||||
},
|
||||
|
||||
// Formula
|
||||
changeFormula: (state, action: PayloadAction<string>) => {
|
||||
state.dirty = true;
|
||||
state.formula = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -201,6 +218,8 @@ export const {
|
||||
changeChartType,
|
||||
changeLineType,
|
||||
resetDirty,
|
||||
changeFormula,
|
||||
changePrevious,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon, FilterIcon } from 'lucide-react';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface EventPropertiesComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function EventPropertiesCombobox({
|
||||
event,
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
const properties = (query.data ?? []).map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
property: value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs',
|
||||
!event.property && 'border-destructive text-destructive'
|
||||
)}
|
||||
>
|
||||
<DatabaseIcon size={12} />{' '}
|
||||
{event.property ? `Property: ${event.property}` : 'Select property'}
|
||||
</button>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const labels = [
|
||||
];
|
||||
|
||||
export interface ReportEventMoreProps {
|
||||
onClick: (action: 'createFilter' | 'remove') => void;
|
||||
onClick: (action: 'remove') => void;
|
||||
}
|
||||
|
||||
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onClick('createFilter')}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Add filter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { Filter, GanttChart, Users } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { GanttChart, Users } from 'lucide-react';
|
||||
|
||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||
import { ReportEventFilters } from './ReportEventFilters';
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
changePrevious,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportEvents() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const params = useAppParams();
|
||||
@@ -37,9 +43,6 @@ export function ReportEvents() {
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'createFilter': {
|
||||
return setIsCreating(true);
|
||||
}
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
}
|
||||
@@ -111,12 +114,20 @@ export function ReportEvents() {
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Unique users (average)',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
@@ -127,12 +138,20 @@ export function ReportEvents() {
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users (average)
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
@@ -140,18 +159,17 @@ export function ReportEvents() {
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleMore(event)('createFilter');
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs"
|
||||
>
|
||||
<Filter size={12} /> Filter
|
||||
</button>
|
||||
{/* */}
|
||||
<FiltersCombobox event={event} />
|
||||
|
||||
{(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<ReportEventFilters {...{ isCreating, setIsCreating, event }} />
|
||||
<FiltersList event={event} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -172,6 +190,17 @@ export function ReportEvents() {
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
|
||||
htmlFor="previous"
|
||||
>
|
||||
<Checkbox
|
||||
id="previous"
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
/>
|
||||
Show previous / Compare
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
apps/web/src/components/report/sidebar/ReportForumula.tsx
Normal file
26
apps/web/src/components/report/sidebar/ReportForumula.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportForumula() {
|
||||
const forumula = useSelector((state) => state.report.formula);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Forumula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="eg: A/B"
|
||||
value={forumula}
|
||||
onChange={(event) => {
|
||||
dispatch(changeFormula(event.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { SheetClose } from '@/components/ui/sheet';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportForumula } from './ReportForumula';
|
||||
|
||||
export function ReportSidebar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pb-12">
|
||||
<ReportEvents />
|
||||
<ReportForumula />
|
||||
<ReportBreakdowns />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
|
||||
<SheetClose asChild>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
@@ -23,93 +13,24 @@ import type {
|
||||
IChartEventFilterValue,
|
||||
} from '@/types';
|
||||
import { operators } from '@/utils/constants';
|
||||
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface ReportEventFiltersProps {
|
||||
event: IChartEvent;
|
||||
isCreating: boolean;
|
||||
setIsCreating: Dispatch<boolean>;
|
||||
}
|
||||
|
||||
export function ReportEventFilters({
|
||||
event,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
}: ReportEventFiltersProps) {
|
||||
const params = useAppParams();
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId: params.projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col divide-y bg-slate-50">
|
||||
{event.filters.map((filter) => {
|
||||
return <Filter key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
|
||||
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
|
||||
<CommandInput placeholder="Search properties" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
|
||||
<CommandGroup heading="Properties">
|
||||
{propertiesQuery.data?.map((item) => (
|
||||
<CommandItem
|
||||
key={item}
|
||||
onSelect={() => {
|
||||
setIsCreating(false);
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: (event.filters.length + 1).toString(),
|
||||
name: item,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<RenderDots className="text-sm">{item}</RenderDots>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
event: IChartEvent;
|
||||
filter: IChartEvent['filters'][number];
|
||||
}
|
||||
|
||||
function Filter({ filter, event }: FilterProps) {
|
||||
const params = useParams<{ organizationId: string; projectId: string }>();
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
projectId: params?.projectId!,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
@@ -0,0 +1,61 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FiltersComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
}
|
||||
);
|
||||
|
||||
const properties = (query.data ?? []).map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: (event.filters.length + 1).toString(),
|
||||
name: value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs">
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { IChartEvent } from '@/types';
|
||||
|
||||
import { FilterItem } from './FilterItem';
|
||||
|
||||
interface ReportEventFiltersProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function FiltersList({ event }: ReportEventFiltersProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col divide-y bg-slate-50">
|
||||
{event.filters.map((filter) => {
|
||||
return <FilterItem key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user