feature(dashboard): add more settings for funnels

* wip
* feature(dashboard): add more settings for funnels
This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-21 10:13:57 +02:00
committed by GitHub
parent 4846390531
commit c4a2ea4858
15 changed files with 276 additions and 212 deletions

View File

@@ -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',
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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>
);
}