feature(dashboard): add more settings for funnels
* wip * feature(dashboard): add more settings for funnels
This commit is contained in:
committed by
GitHub
parent
4846390531
commit
c4a2ea4858
@@ -13,7 +13,7 @@ import { Chart } from './chart';
|
||||
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: { events, range, projectId },
|
||||
report: { events, range, projectId, funnelWindow, funnelGroup },
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function ReportFunnelChart() {
|
||||
interval: 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns: [],
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
@@ -8,12 +8,18 @@ import { api, handleError } from '@/trpc/client';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useIsFetching } from '@tanstack/react-query';
|
||||
import { getQueryKey } from '@trpc/react-query';
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
interface ReportSaveButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||
const fetching = [
|
||||
useIsFetching(getQueryKey(api.chart.chart)),
|
||||
useIsFetching(getQueryKey(api.chart.cohort)),
|
||||
];
|
||||
const { reportId } = useAppParams<{ reportId: string | undefined }>();
|
||||
const dispatch = useDispatch();
|
||||
const update = api.report.update.useMutation({
|
||||
@@ -26,13 +32,14 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||
onError: handleError,
|
||||
});
|
||||
const report = useSelector((state) => state.report);
|
||||
const isLoading = update.isLoading || fetching.some((f) => f !== 0);
|
||||
|
||||
if (reportId) {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={!report.dirty}
|
||||
loading={update.isLoading}
|
||||
loading={update.isLoading || isLoading}
|
||||
onClick={() => {
|
||||
update.mutate({
|
||||
reportId: reportId,
|
||||
@@ -55,6 +62,7 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||
});
|
||||
}}
|
||||
icon={SaveIcon}
|
||||
loading={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -56,6 +56,8 @@ const initialState: InitialState = {
|
||||
metric: 'sum',
|
||||
limit: 500,
|
||||
criteria: 'on_or_after',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: undefined,
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -266,6 +268,16 @@ export const reportSlice = createSlice({
|
||||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -293,6 +305,8 @@ export const {
|
||||
changePrevious,
|
||||
changeCriteria,
|
||||
changeUnit,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { RefreshCcwIcon } from 'lucide-react';
|
||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportFormula() {
|
||||
@@ -17,7 +13,7 @@ export function ReportFormula() {
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Formula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputFormula
|
||||
<InputEnter
|
||||
placeholder="eg: A/B"
|
||||
value={formula ?? ''}
|
||||
onChangeValue={(value) => {
|
||||
@@ -28,54 +24,3 @@ export function ReportFormula() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputFormula({
|
||||
value,
|
||||
onChangeValue,
|
||||
...props
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChangeValue: (value: string) => void;
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== internalValue) {
|
||||
setInternalValue(value ?? '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
{...props}
|
||||
value={internalValue}
|
||||
onChange={(e) => setInternalValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeValue(internalValue);
|
||||
}
|
||||
}}
|
||||
size="default"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<AnimatePresence>
|
||||
{internalValue !== value && (
|
||||
<motion.button
|
||||
key="refresh"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={() => onChangeValue(internalValue)}
|
||||
>
|
||||
<Badge variant="muted">
|
||||
Press enter
|
||||
<RefreshCcwIcon className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
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, changePrevious, changeUnit } from '../reportSlice';
|
||||
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();
|
||||
|
||||
@@ -28,6 +37,11 @@ export function ReportSettings() {
|
||||
fields.push('unit');
|
||||
}
|
||||
|
||||
if (chartType === 'funnel') {
|
||||
fields.push('funnelGroup');
|
||||
fields.push('funnelWindow');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
@@ -41,7 +55,9 @@ export function ReportSettings() {
|
||||
<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>Compare to previous period</span>
|
||||
<span className="whitespace-nowrap">
|
||||
Compare to previous period
|
||||
</span>
|
||||
<Switch
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
@@ -49,8 +65,8 @@ export function ReportSettings() {
|
||||
</Label>
|
||||
)}
|
||||
{fields.includes('criteria') && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Criteria</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Criteria</span>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select criteria"
|
||||
@@ -70,8 +86,8 @@ export function ReportSettings() {
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('unit') && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Unit</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Unit</span>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Unit"
|
||||
@@ -92,6 +108,49 @@ export function ReportSettings() {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { RefreshCcwIcon, SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
|
||||
import { operators } from '@openpanel/constants';
|
||||
import type {
|
||||
@@ -19,11 +15,10 @@ import type {
|
||||
IChartEventFilter,
|
||||
IChartEventFilterOperator,
|
||||
IChartEventFilterValue,
|
||||
IChartRange,
|
||||
IInterval,
|
||||
} from '@openpanel/validation';
|
||||
import { mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
@@ -185,7 +180,7 @@ export function PureFilterItem({
|
||||
placeholder="Select..."
|
||||
/>
|
||||
) : (
|
||||
<FilterRawInput
|
||||
<InputEnter
|
||||
value={filter.value[0] ? String(filter.value[0]) : ''}
|
||||
onChangeValue={(value) => changeFilterValue([value])}
|
||||
/>
|
||||
@@ -194,53 +189,3 @@ export function PureFilterItem({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRawInput({
|
||||
value,
|
||||
onChangeValue,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeValue: (value: string) => void;
|
||||
}) {
|
||||
const [internalValue, setInternalValue] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== internalValue) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
value={internalValue}
|
||||
onChange={(e) => setInternalValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeValue(internalValue);
|
||||
}
|
||||
}}
|
||||
placeholder="Value"
|
||||
size="default"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<AnimatePresence>
|
||||
{internalValue !== value && (
|
||||
<motion.button
|
||||
key="refresh"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={() => onChangeValue(internalValue)}
|
||||
>
|
||||
<Badge variant="muted">
|
||||
Press enter
|
||||
<RefreshCcwIcon className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/dashboard/src/components/ui/input-enter.tsx
Normal file
60
apps/dashboard/src/components/ui/input-enter.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { RefreshCcwIcon } from 'lucide-react';
|
||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
||||
import { Badge } from './badge';
|
||||
import { Input } from './input';
|
||||
|
||||
export function InputEnter({
|
||||
value,
|
||||
onChangeValue,
|
||||
...props
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChangeValue: (value: string) => void;
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== internalValue) {
|
||||
console.log(value, internalValue);
|
||||
|
||||
setInternalValue(value ?? '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
{...props}
|
||||
value={internalValue}
|
||||
onChange={(e) => setInternalValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeValue(internalValue);
|
||||
}
|
||||
}}
|
||||
size="default"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<AnimatePresence>
|
||||
{internalValue !== value && (
|
||||
<motion.button
|
||||
key="refresh"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={() => onChangeValue(internalValue)}
|
||||
>
|
||||
<Badge variant="muted">
|
||||
Press enter
|
||||
<RefreshCcwIcon className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user