feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,98 @@
import { useDispatch, useSelector } from '@/redux';
import {
AreaChartIcon,
ChartBarIcon,
ChartColumnIncreasingIcon,
ConeIcon,
GaugeIcon,
Globe2Icon,
LineChartIcon,
type LucideIcon,
PieChartIcon,
TrendingUpIcon,
UsersIcon,
} from 'lucide-react';
import { chartTypes } from '@openpanel/constants';
import { type IChartType, objectToZodEnums } from '@openpanel/validation';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
import { changeChartType } from './reportSlice';
interface ReportChartTypeProps {
className?: string;
value: IChartType;
onChange: (type: IChartType) => void;
}
export function ReportChartType({
className,
value,
onChange,
}: ReportChartTypeProps) {
const items = objectToZodEnums(chartTypes).map((key) => ({
label: chartTypes[key],
value: key,
}));
const Icons: Record<keyof typeof chartTypes, LucideIcon> = {
area: AreaChartIcon,
bar: ChartBarIcon,
pie: PieChartIcon,
funnel: ((props) => (
<ConeIcon className={cn('rotate-180', props.className)} />
)) as LucideIcon,
histogram: ChartColumnIncreasingIcon,
linear: LineChartIcon,
metric: GaugeIcon,
retention: UsersIcon,
map: Globe2Icon,
conversion: TrendingUpIcon,
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start', className)}
>
{items.find((item) => item.value === value)?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Available charts</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{items.map((item) => {
const Icon = Icons[item.value];
return (
<DropdownMenuItem
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" />
</DropdownMenuShortcut>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,121 @@
import { useDispatch, useSelector } from '@/redux';
import { ClockIcon } from 'lucide-react';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import { cn } from '@/utils/cn';
import type { IChartRange, IChartType, IInterval } from '@openpanel/validation';
import { Button } from '../ui/button';
import { CommandShortcut } from '../ui/command';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { changeInterval } from './reportSlice';
interface ReportIntervalProps {
className?: string;
interval: IInterval;
onChange: (range: IInterval) => void;
chartType: IChartType;
range: IChartRange;
}
export function ReportInterval({
className,
interval,
onChange,
chartType,
range,
}: ReportIntervalProps) {
if (
chartType !== 'linear' &&
chartType !== 'histogram' &&
chartType !== 'area' &&
chartType !== 'metric' &&
chartType !== 'retention' &&
chartType !== 'conversion'
) {
return null;
}
const items = [
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'week',
label: 'Week',
disabled:
range === 'today' ||
range === 'lastHour' ||
range === '30min' ||
range === '7d',
},
{
value: 'month',
label: 'Month',
disabled: range === 'today' || range === 'lastHour' || range === '30min',
},
];
const selectedItem = items.find((item) => item.value === interval);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={ClockIcon}
className={cn('justify-start', className)}
>
{items.find((item) => item.value === interval)?.label || 'Interval'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="row items-center justify-between">
Select interval
{!!selectedItem && (
<CommandShortcut>{selectedItem?.label}</CommandShortcut>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value as IInterval)}
disabled={item.disabled}
>
{item.label}
{item.value === interval && (
<DropdownMenuShortcut>
<ClockIcon className="size-4" />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,40 @@
import { useDispatch, useSelector } from '@/redux';
import { Tv2Icon } from 'lucide-react';
import { lineTypes } from '@openpanel/constants';
import { objectToZodEnums } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeLineType } from './reportSlice';
interface ReportLineTypeProps {
className?: string;
}
export function ReportLineType({ className }: ReportLineTypeProps) {
const dispatch = useDispatch();
const chartType = useSelector((state) => state.report.chartType);
const type = useSelector((state) => state.report.lineType);
if (
chartType !== 'conversion' &&
chartType !== 'linear' &&
chartType !== 'area'
)
return null;
return (
<Combobox
icon={Tv2Icon}
className={className}
placeholder="Line type"
onChange={(value) => {
dispatch(changeLineType(value));
}}
value={type}
items={objectToZodEnums(lineTypes).map((key) => ({
label: lineTypes[key],
value: key,
}))}
/>
);
}

View File

@@ -0,0 +1,72 @@
import { Button } from '@/components/ui/button';
import { handleError } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react';
import { toast } from 'sonner';
import { useTRPC } from '@/integrations/trpc/react';
import { useIsFetching, useMutation } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
import { resetDirty } from './reportSlice';
interface ReportSaveButtonProps {
className?: string;
}
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
const trpc = useTRPC();
const fetching = [
useIsFetching(trpc.chart.chart.pathFilter()),
useIsFetching(trpc.chart.cohort.pathFilter()),
];
const { reportId } = useParams({ strict: false });
const dispatch = useDispatch();
const update = useMutation(
trpc.report.update.mutationOptions({
onSuccess() {
dispatch(resetDirty());
toast('Success', {
description: 'Report updated.',
});
},
onError: handleError,
}),
);
const report = useSelector((state) => state.report);
const isLoading = update.isPending || fetching.some((f) => f !== 0);
if (reportId) {
return (
<Button
className={className}
disabled={!report.dirty}
loading={update.isPending || isLoading}
onClick={() => {
update.mutate({
reportId: reportId,
report,
});
}}
icon={SaveIcon}
>
Update
</Button>
);
}
return (
<Button
className={className}
disabled={!report.dirty}
onClick={() => {
pushModal('SaveReport', {
report,
});
}}
icon={SaveIcon}
loading={isLoading}
>
Save
</Button>
);
}

View File

@@ -0,0 +1,92 @@
import {
ActivityIcon,
ClockIcon,
EqualApproximatelyIcon,
type LucideIcon,
SigmaIcon,
TrendingDownIcon,
TrendingUpIcon,
UserCheck2Icon,
UserCheckIcon,
UsersIcon,
} from 'lucide-react';
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
interface ReportChartTypeProps {
className?: string;
value: IChartEventSegment;
onChange: (segment: IChartEventSegment) => void;
}
export function ReportSegment({
className,
value,
onChange,
}: ReportChartTypeProps) {
const items = mapKeys(chartSegments).map((key) => ({
label: chartSegments[key],
value: key,
}));
const Icons: Record<IChartEventSegment, LucideIcon> = {
event: ActivityIcon,
user: UsersIcon,
session: ClockIcon,
user_average: UserCheck2Icon,
one_event_per_user: UserCheckIcon,
property_sum: SigmaIcon,
property_average: EqualApproximatelyIcon,
property_max: TrendingUpIcon,
property_min: TrendingDownIcon,
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start text-sm', className)}
>
{items.find((item) => item.value === value)?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Available charts</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{items.map((item) => {
const Icon = Icons[item.value];
return (
<DropdownMenuItem
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" />
</DropdownMenuShortcut>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,71 @@
import { PencilIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
type Props = {
name?: string;
};
const EditReportName = ({ name }: Props) => {
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
if (newName === name) {
return setIsEditing(false);
}
if (newName === '') {
setNewName(name);
}
window.dispatchEvent(
new CustomEvent('report-name-change', {
detail: newName === '' ? name : newName,
}),
);
setIsEditing(false);
};
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
}
}, [isEditing]);
if (isEditing) {
return (
<div className="flex">
<input
ref={inputRef}
type="text"
className="w-full rounded-md border border-input p-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={newName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onSubmit();
}
}}
onChange={(e) => setNewName(e.target.value)}
onBlur={() => onSubmit()}
/>
</div>
);
}
return (
<button
type="button"
className="flex cursor-pointer select-none items-center gap-2"
onClick={() => setIsEditing(true)}
>
{newName ?? 'Unnamed Report'}
<PencilIcon size={16} />
</button>
);
};
export default EditReportName;

View File

@@ -0,0 +1,305 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
import { shortId } from '@openpanel/common';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartLineType,
IChartProps,
IChartRange,
IChartType,
IInterval,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
type InitialState = IChartProps & {
dirty: boolean;
ready: boolean;
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: '',
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
range: '30d',
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
funnelGroup: undefined,
funnelWindow: undefined,
};
export const reportSlice = createSlice({
name: 'report',
initialState,
reducers: {
resetDirty(state) {
return {
...state,
dirty: false,
};
},
reset() {
return initialState;
},
ready() {
return {
...initialState,
ready: true,
};
},
setReport(state, action: PayloadAction<IChartProps>) {
return {
...state,
...action.payload,
startDate: null,
endDate: null,
dirty: false,
ready: true,
};
},
setName(state, action: PayloadAction<string>) {
state.dirty = true;
state.name = action.payload;
},
// Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
id: shortId(),
...action.payload,
});
},
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
});
},
removeEvent: (
state,
action: PayloadAction<{
id?: string;
}>,
) => {
state.dirty = true;
state.events = state.events.filter(
(event) => event.id !== action.payload.id,
);
},
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
state.dirty = true;
state.events = state.events.map((event) => {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
});
},
// Previous
changePrevious: (state, action: PayloadAction<boolean>) => {
state.dirty = true;
state.previous = action.payload;
},
// Breakdowns
addBreakdown: (
state,
action: PayloadAction<Omit<IChartBreakdown, 'id'>>,
) => {
state.dirty = true;
state.breakdowns.push({
id: shortId(),
...action.payload,
});
},
removeBreakdown: (
state,
action: PayloadAction<{
id?: string;
}>,
) => {
state.dirty = true;
state.breakdowns = state.breakdowns.filter(
(event) => event.id !== action.payload.id,
);
},
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
state.dirty = true;
state.breakdowns = state.breakdowns.map((breakdown) => {
if (breakdown.id === action.payload.id) {
return action.payload;
}
return breakdown;
});
},
// Interval
changeInterval: (state, action: PayloadAction<IInterval>) => {
state.dirty = true;
state.interval = action.payload;
},
// Chart type
changeChartType: (state, action: PayloadAction<IChartType>) => {
state.dirty = true;
state.chartType = action.payload;
if (
!isMinuteIntervalEnabledByRange(state.range) &&
state.interval === 'minute'
) {
state.interval = 'hour';
}
if (
!isHourIntervalEnabledByRange(state.range) &&
state.interval === 'hour'
) {
state.interval = 'day';
}
},
// Line type
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
state.dirty = true;
state.lineType = action.payload;
},
// Date range
changeStartDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.startDate = action.payload;
const interval = getDefaultIntervalByDates(
state.startDate,
state.endDate,
);
if (interval) {
state.interval = interval;
}
},
// Date range
changeEndDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.endDate = action.payload;
const interval = getDefaultIntervalByDates(
state.startDate,
state.endDate,
);
if (interval) {
state.interval = interval;
}
},
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
if (action.payload !== 'custom') {
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
}
},
// Formula
changeFormula: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.formula = action.payload;
},
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
state.dirty = true;
state.criteria = action.payload;
},
changeUnit(state, action: PayloadAction<string | undefined>) {
state.dirty = true;
state.unit = action.payload || undefined;
},
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
state.dirty = true;
state.funnelGroup = action.payload || undefined;
},
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
state.dirty = true;
state.funnelWindow = action.payload || undefined;
},
reorderEvents(
state,
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
) {
state.dirty = true;
const { fromIndex, toIndex } = action.payload;
const [movedEvent] = state.events.splice(fromIndex, 1);
if (movedEvent) {
state.events.splice(toIndex, 0, movedEvent);
}
},
},
});
// Action creators are generated for each case reducer function
export const {
reset,
ready,
setReport,
setName,
addEvent,
removeEvent,
duplicateEvent,
changeEvent,
addBreakdown,
removeBreakdown,
changeBreakdown,
changeInterval,
changeStartDate,
changeEndDate,
changeDateRanges,
changeChartType,
changeLineType,
resetDirty,
changeFormula,
changePrevious,
changeCriteria,
changeUnit,
changeFunnelGroup,
changeFunnelWindow,
reorderEvents,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -0,0 +1,62 @@
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useDispatch, useSelector } from '@/redux';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react';
import type { IChartEvent } from '@openpanel/validation';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {
event: IChartEvent;
}
export function EventPropertiesCombobox({
event,
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const properties = useEventProperties(
{
event: event.name,
projectId,
},
{
enabled: !!event.name,
},
).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
onChange={(value) => {
dispatch(
changeEvent({
...event,
property: value,
}),
);
}}
>
<button
type="button"
className={cn(
'flex items-center gap-1 rounded-md border border-border p-1 px-2 text-sm font-medium leading-none',
!event.property && 'border-destructive text-destructive',
)}
>
<DatabaseIcon size={12} />{' '}
{event.property ? `Property: ${event.property}` : 'Select property'}
</button>
</Combobox>
);
}

View File

@@ -0,0 +1,260 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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';
interface PropertiesComboboxProps {
event?: IChartEvent;
children: (setOpen: Dispatch<SetStateAction<boolean>>) => React.ReactNode;
onSelect: (action: {
value: string;
label: string;
description: string;
}) => void;
}
function SearchHeader({
onBack,
onSearch,
value,
}: {
onBack?: () => void;
onSearch: (value: string) => void;
value: string;
}) {
return (
<div className="row items-center gap-1">
{!!onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
)}
<Input
placeholder="Search"
value={value}
onChange={(e) => onSearch(e.target.value)}
autoFocus
/>
</div>
);
}
export function PropertiesCombobox({
event,
children,
onSelect,
}: PropertiesComboboxProps) {
const { projectId } = useAppParams();
const [open, setOpen] = useState(false);
const properties = useEventProperties({
event: event?.name,
projectId,
});
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
const [search, setSearch] = useState('');
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
useEffect(() => {
if (!open) {
setState('index');
}
}, [open]);
// Mock data for the lists
const profileActions = properties
.filter((property) => property.startsWith('profile'))
.map((property) => ({
value: property,
label: property.split('.').pop() ?? property,
description: property.split('.').slice(0, -1).join('.'),
}));
const eventActions = properties
.filter((property) => !property.startsWith('profile'))
.map((property) => ({
value: property,
label: property.split('.').pop() ?? property,
description: property.split('.').slice(0, -1).join('.'),
}));
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
setDirection(newState === 'index' ? 'backward' : 'forward');
setState(newState);
};
const handleSelect = (action: {
value: string;
label: string;
description: string;
}) => {
setOpen(false);
onSelect(action);
};
const renderIndex = () => {
return (
<DropdownMenuGroup>
{/* <SearchHeader onSearch={() => {}} value={search} /> */}
{/* <DropdownMenuSeparator /> */}
<DropdownMenuItem
className="group justify-between"
onClick={(e) => {
e.preventDefault();
handleStateChange('event');
}}
>
Event properties
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
</DropdownMenuItem>
<DropdownMenuItem
className="group justify-between"
onClick={(e) => {
e.preventDefault();
handleStateChange('profile');
}}
>
Profile properties
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
</DropdownMenuItem>
</DropdownMenuGroup>
);
};
const renderEvent = () => {
const filteredActions = eventActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="col">
<SearchHeader
onBack={() => handleStateChange('index')}
onSearch={setSearch}
value={search}
/>
<DropdownMenuSeparator />
<VirtualList
height={300}
data={filteredActions}
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"
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground">
{action.description}
</div>
</motion.div>
)}
</VirtualList>
</div>
);
};
const renderProfile = () => {
const filteredActions = profileActions.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
height={300}
data={filteredActions}
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"
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground">
{action.description}
</div>
</motion.div>
)}
</VirtualList>
</div>
);
};
return (
<DropdownMenu
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-80" align="start">
<AnimatePresence mode="wait" initial={false}>
{state === 'index' && (
<motion.div
key="index"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.05 }}
>
{renderIndex()}
</motion.div>
)}
{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 }}
transition={{ duration: 0.05 }}
>
{renderEvent()}
</motion.div>
)}
{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 }}
transition={{ duration: 0.05 }}
>
{renderProfile()}
</motion.div>
)}
</AnimatePresence>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,41 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Trash } from 'lucide-react';
import * as React from 'react';
export interface ReportBreakdownMoreProps {
onClick: (action: 'remove') => void;
}
export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) {
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-red-600"
onClick={() => onClick('remove')}
>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,101 @@
import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useDispatch, useSelector } from '@/redux';
import { ChevronsUpDownIcon, SplitIcon } from 'lucide-react';
import type { IChartBreakdown } from '@openpanel/validation';
import { Button } from '@/components/ui/button';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { PropertiesCombobox } from './PropertiesCombobox';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const handleMore = (breakdown: IChartBreakdown) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeBreakdown(breakdown));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Breakdown</h3>
<div className="flex flex-col gap-4">
{selectedBreakdowns.map((item, index) => {
return (
<div key={item.name} className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare>
<PropertiesCombobox
onSelect={(action) => {
dispatch(
changeBreakdown({
...item,
name: action.value,
}),
);
}}
>
{(setOpen) => (
<Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
size={'sm'}
autoHeight
className="flex-1"
>
<div className="row w-full gap-2 items-center">
<SplitIcon className="size-4" />
{item.name}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PropertiesCombobox>
<ReportBreakdownMore onClick={handleMore(item)} />
</div>
</div>
);
})}
<PropertiesCombobox
onSelect={(action) => {
dispatch(
addBreakdown({
name: action.value,
}),
);
}}
>
{(setOpen) => (
<Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
size={'sm'}
autoHeight
className="flex-1"
>
<div className="row w-full gap-2 items-center">
<SplitIcon className="size-4" />
Select breakdown
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PropertiesCombobox>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CopyIcon, MoreHorizontal, TrashIcon } from 'lucide-react';
import * as React from 'react';
export interface ReportEventMoreProps {
onClick: (action: 'remove' | 'duplicate') => void;
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('duplicate')}>
<CopyIcon className="mr-2 h-4 w-4" />
Duplicate
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => onClick('remove')}
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,310 @@
import { ColorSquare } from '@/components/color-square';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names';
import { useDispatch, useSelector } from '@/redux';
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation';
import { FilterIcon, HandIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
changeEvent,
duplicateEvent,
removeEvent,
reorderEvents,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { FiltersList } from './filters/FiltersList';
function SortableEvent({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
...props
}: {
event: IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: event.id ?? '' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
{props.children}
</div>
{/* Segment and Filter buttons */}
{(showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={event.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...event,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={event}
onSelect={(action) => {
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && event.segment.startsWith('property_') && (
<EventPropertiesCombobox event={event} />
)}
</div>
)}
{/* Filters */}
{!isSelectManyEvents && <FiltersList event={event} />}
</div>
);
}
export function ReportEvents() {
const selectedEvents = useSelector((state) => state.report.events);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedEvents.findIndex((e) => e.id === active.id);
const newIndex = selectedEvents.findIndex((e) => e.id === over.id);
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
}
};
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeEvent(event));
}
case 'duplicate': {
return dispatch(duplicateEvent(event));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Events</h3>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => {
return (
<SortableEvent
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? (event.filters[0]?.value ?? [])
: event.name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</SortableEvent>
);
})}
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useDispatch, useSelector } from '@/redux';
import { InputEnter } from '@/components/ui/input-enter';
import { changeFormula } from '../reportSlice';
export function ReportFormula() {
const formula = useSelector((state) => state.report.formula);
const dispatch = useDispatch();
return (
<div>
<h3 className="mb-2 font-medium">Formula</h3>
<div className="flex flex-col gap-4">
<InputEnter
placeholder="eg: A/B"
value={formula ?? ''}
onChangeValue={(value) => {
dispatch(changeFormula(value));
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { Combobox } from '@/components/ui/combobox';
import { useDispatch, useSelector } from '@/redux';
import { InputEnter } from '@/components/ui/input-enter';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useMemo } from 'react';
import {
changeCriteria,
changeFunnelGroup,
changeFunnelWindow,
changePrevious,
changeUnit,
} from '../reportSlice';
export function ReportSettings() {
const chartType = useSelector((state) => state.report.chartType);
const previous = useSelector((state) => state.report.previous);
const criteria = useSelector((state) => state.report.criteria);
const unit = useSelector((state) => state.report.unit);
const funnelGroup = useSelector((state) => state.report.funnelGroup);
const funnelWindow = useSelector((state) => state.report.funnelWindow);
const dispatch = useDispatch();
const fields = useMemo(() => {
const fields = [];
if (chartType !== 'retention') {
fields.push('previous');
}
if (chartType === 'retention') {
fields.push('criteria');
fields.push('unit');
}
if (chartType === 'funnel' || chartType === 'conversion') {
fields.push('funnelGroup');
fields.push('funnelWindow');
}
return fields;
}, [chartType]);
if (fields.length === 0) {
return null;
}
return (
<div>
<h3 className="mb-2 font-medium">Settings</h3>
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
{fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap">
Compare to previous period
</span>
<Switch
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
</Label>
)}
{fields.includes('criteria') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Criteria</span>
<Combobox
align="end"
placeholder="Select criteria"
value={criteria}
onChange={(val) => dispatch(changeCriteria(val))}
items={[
{
label: 'On or After',
value: 'on_or_after',
},
{
label: 'On',
value: 'on',
},
]}
/>
</div>
)}
{fields.includes('unit') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Unit</span>
<Combobox
align="end"
placeholder="Unit"
value={unit || 'count'}
onChange={(val) => {
dispatch(changeUnit(val === 'count' ? undefined : val));
}}
items={[
{
label: 'Count',
value: 'count',
},
{
label: '%',
value: '%',
},
]}
/>
</div>
)}
{fields.includes('funnelGroup') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Group</span>
<Combobox
align="end"
placeholder="Default: Session"
value={funnelGroup || 'session_id'}
onChange={(val) => {
dispatch(
changeFunnelGroup(val === 'session_id' ? undefined : val),
);
}}
items={[
{
label: 'Session',
value: 'session_id',
},
{
label: 'Profile',
value: 'profile_id',
},
]}
/>
</div>
)}
{fields.includes('funnelWindow') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Window</span>
<InputEnter
type="number"
value={funnelWindow ? String(funnelWindow) : ''}
placeholder="Default: 24h"
onChangeValue={(value) => {
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) {
dispatch(changeFunnelWindow(undefined));
} else {
dispatch(changeFunnelWindow(parsed));
}
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Button } from '@/components/ui/button';
import { SheetClose, SheetFooter } from '@/components/ui/sheet';
import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
const showBreakdown = chartType !== 'retention';
return (
<>
<div className="flex flex-col gap-8">
<ReportEvents />
{showBreakdown && <ReportBreakdowns />}
{showFormula && <ReportFormula />}
<ReportSettings />
</div>
<SheetFooter>
<SheetClose asChild>
<Button className="w-full">Done</Button>
</SheetClose>
</SheetFooter>
</>
);
}

View File

@@ -0,0 +1,187 @@
import { ColorSquare } from '@/components/color-square';
import { RenderDots } from '@/components/ui/RenderDots';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params';
import { usePropertyValues } from '@/hooks/use-property-values';
import { useDispatch } from '@/redux';
import { operators } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { changeEvent } from '../../reportSlice';
interface FilterProps {
event: IChartEvent;
filter: IChartEventFilter;
}
interface PureFilterProps {
eventName: string;
filter: IChartEventFilter;
onRemove: (filter: IChartEventFilter) => void;
onChangeValue: (
value: IChartEventFilterValue[],
filter: IChartEventFilter,
) => void;
onChangeOperator: (
operator: IChartEventFilterOperator,
filter: IChartEventFilter,
) => void;
className?: string;
}
export function FilterItem({ filter, event }: FilterProps) {
// const { range, startDate, endDate, interval } = useSelector(
// (state) => state.report,
// );
const onRemove = ({ id }: IChartEventFilter) => {
dispatch(
changeEvent({
...event,
filters: event.filters.filter((item) => item.id !== id),
}),
);
};
const onChangeValue = (
value: IChartEventFilterValue[],
{ id }: IChartEventFilter,
) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === id) {
return {
...item,
value,
};
}
return item;
}),
}),
);
};
const onChangeOperator = (
operator: IChartEventFilterOperator,
{ id }: IChartEventFilter,
) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === id) {
return {
...item,
value: item.value ? item.value.filter(Boolean).slice(0, 1) : [],
operator,
};
}
return item;
}),
}),
);
};
const dispatch = useDispatch();
return (
<PureFilterItem
filter={filter}
eventName={event.name}
onRemove={onRemove}
onChangeValue={onChangeValue}
onChangeOperator={onChangeOperator}
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-def-300 first:border-t"
/>
);
}
export function PureFilterItem({
filter,
eventName,
onRemove,
onChangeValue,
onChangeOperator,
className,
}: PureFilterProps) {
const { projectId } = useAppParams();
const potentialValues = usePropertyValues({
event: eventName,
property: filter.name,
projectId,
});
const valuesCombobox =
potentialValues.map((item) => ({
value: item,
label: item,
})) ?? [];
const removeFilter = () => {
onRemove(filter);
};
const changeFilterValue = (value: IChartEventFilterValue[]) => {
onChangeValue(value, filter);
};
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
onChangeOperator(operator, filter);
};
return (
<div className={className}>
<div className="mb-2 flex items-center gap-2">
<ColorSquare className="bg-emerald-500">
<SlidersHorizontal size={10} />
</ColorSquare>
<div className="flex flex-1 ">
<RenderDots truncate>{filter.name}</RenderDots>
</div>
<Button variant="ghost" size="sm" onClick={removeFilter}>
<Trash size={16} />
</Button>
</div>
<div className="flex gap-1">
<DropdownMenuComposed
onChange={changeFilterOperator}
items={mapKeys(operators).map((key) => ({
value: key,
label: operators[key],
}))}
label="Operator"
>
<Button variant={'outline'} className="whitespace-nowrap">
{operators[filter.operator]}
</Button>
</DropdownMenuComposed>
{filter.operator === 'is' || filter.operator === 'isNot' ? (
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}
className="flex-1"
onChange={changeFilterValue}
placeholder="Select..."
/>
) : (
<InputEnter
value={filter.value[0] ? String(filter.value[0]) : ''}
onChangeValue={(value) => changeFilterValue([value])}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { IChartEvent } from '@openpanel/validation';
import { FilterItem } from './FilterItem';
interface ReportEventFiltersProps {
event: IChartEvent;
}
export function FiltersList({ event }: ReportEventFiltersProps) {
return (
<div>
<div className="bg-def-100 flex flex-col divide-y overflow-hidden rounded-b-md">
{event.filters.map((filter) => {
return <FilterItem key={filter.name} filter={filter} event={event} />;
})}
</div>
</div>
);
}