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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
98
apps/start/src/components/report/ReportChartType.tsx
Normal file
98
apps/start/src/components/report/ReportChartType.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
apps/start/src/components/report/ReportInterval.tsx
Normal file
121
apps/start/src/components/report/ReportInterval.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/start/src/components/report/ReportLineType.tsx
Normal file
40
apps/start/src/components/report/ReportLineType.tsx
Normal 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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
apps/start/src/components/report/ReportSaveButton.tsx
Normal file
72
apps/start/src/components/report/ReportSaveButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
apps/start/src/components/report/ReportSegment.tsx
Normal file
92
apps/start/src/components/report/ReportSegment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/start/src/components/report/edit-report-name.tsx
Normal file
71
apps/start/src/components/report/edit-report-name.tsx
Normal 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;
|
||||
305
apps/start/src/components/report/reportSlice.ts
Normal file
305
apps/start/src/components/report/reportSlice.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
260
apps/start/src/components/report/sidebar/PropertiesCombobox.tsx
Normal file
260
apps/start/src/components/report/sidebar/PropertiesCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
101
apps/start/src/components/report/sidebar/ReportBreakdowns.tsx
Normal file
101
apps/start/src/components/report/sidebar/ReportBreakdowns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/start/src/components/report/sidebar/ReportEventMore.tsx
Normal file
46
apps/start/src/components/report/sidebar/ReportEventMore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
apps/start/src/components/report/sidebar/ReportEvents.tsx
Normal file
310
apps/start/src/components/report/sidebar/ReportEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/start/src/components/report/sidebar/ReportFormula.tsx
Normal file
24
apps/start/src/components/report/sidebar/ReportFormula.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
apps/start/src/components/report/sidebar/ReportSettings.tsx
Normal file
155
apps/start/src/components/report/sidebar/ReportSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/start/src/components/report/sidebar/ReportSidebar.tsx
Normal file
32
apps/start/src/components/report/sidebar/ReportSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
187
apps/start/src/components/report/sidebar/filters/FilterItem.tsx
Normal file
187
apps/start/src/components/report/sidebar/filters/FilterItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user