diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx index dc11544d9..589831cf5 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -1,32 +1,33 @@ import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useNavigation, useResultQuery } from '@/components/hooks'; +import { useMessages, useNavigation, useOperatorLabels, useResultQuery } from '@/components/hooks'; import { File, User } from '@/components/icons'; import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ChangeLabel } from '@/components/metrics/ChangeLabel'; import { Lightning } from '@/components/svg'; import { formatLongNumber } from '@/lib/format'; +import type { FunnelResult } from '@/queries/sql/reports/getFunnel'; import { FunnelEditForm } from './FunnelEditForm'; -type FunnelResult = { +interface FunnelProps { + id: string; + name: string; type: string; - value: string; - visitors: number; - previous: number; - dropped: number; - dropoff: number; - remaining: number; -}; + parameters: Record; + websiteId: string; +} export function Funnel({ id, name, type, parameters, websiteId }) { const { t, labels } = useMessages(); const { pathname } = useNavigation(); const isSharePage = pathname.includes('/share/'); - const { data, error, isLoading } = useResultQuery(type, { + const { data, error, isLoading } = useResultQuery>(type, { websiteId, ...parameters, }); + const operatorLabels = useOperatorLabels(); + return ( @@ -54,7 +55,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) { {data?.map( ( - { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult, + { type, value, filters, visitors, previous, dropped, dropoff, remaining }: FunnelResult, index: number, ) => { const isPage = type === 'path'; @@ -92,10 +93,25 @@ export function Funnel({ id, name, type, parameters, websiteId }) { {t(labels.conversionRate)} - - {type === 'path' ? : } - {value} - + + + {type === 'path' ? : } + {value} + + {filters?.map((f, i) => ( + + + {f.property} + + + {operatorLabels[f.operator] ?? f.operator} + + + {f.value} + + + ))} + {index > 0 && ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx index a00c916de..4edad4f54 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx @@ -1,6 +1,7 @@ import { Button, Column, + ComboBox, Form, FormButtons, FormField, @@ -8,18 +9,313 @@ import { FormSubmitButton, Grid, Icon, + ListItem, + ListSeparator, Loading, Row, + Select, Text, TextField, + useDebounce, } from '@umami/react-zen'; -import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; +import { endOfDay, subMonths } from 'date-fns'; +import { Fragment, useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useApi, useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; import { Plus, X } from '@/components/icons'; import { ActionSelect } from '@/components/input/ActionSelect'; import { LookupField } from '@/components/input/LookupField'; const FUNNEL_STEPS_MAX = 8; +function getEventDataDateRange() { + return { + startAt: +subMonths(endOfDay(new Date()), 6), + endAt: +endOfDay(new Date()), + }; +} + +function PropertySelect({ + websiteId, + eventName, + value, + onChange, + onPropertyChange, +}: { + websiteId: string; + eventName?: string; + value?: string; + onChange?: (value: string) => void; + onPropertyChange?: (value: string) => void; +}) { + const { get, useQuery } = useApi(); + const { formatMessage, messages } = useMessages(); + const [search, setSearch] = useState(value ?? ''); + const searchValue = useDebounce(search, 300); + const { startAt, endAt } = getEventDataDateRange(); + + const { data, isLoading } = useQuery< + Array<{ eventName: string; propertyName: string; total: number }> + >({ + queryKey: ['event-data:properties', { websiteId, eventName, searchValue, startAt, endAt }], + queryFn: () => + get(`/websites/${websiteId}/event-data/properties`, { + startAt, + endAt, + ...(eventName ? { event: eventName } : {}), + ...(searchValue ? { propertyName: searchValue } : {}), + }), + enabled: !!websiteId, + }); + + const properties = [...new Set(data?.map(d => d.propertyName) ?? [])]; + + return ( + { + setSearch(v); + onChange?.(v); + onPropertyChange?.(v); + }} + formValue="text" + allowsEmptyCollection + allowsCustomValue + renderEmptyState={() => + isLoading ? ( + + ) : ( + + ) + } + > + {properties.map(p => ( + + {p} + + ))} + + ); +} + +function ValueSelect({ + websiteId, + eventName, + propertyName, + value, + onChange, +}: { + websiteId: string; + eventName?: string; + propertyName?: string; + value?: string; + onChange?: (value: string) => void; +}) { + const { get, useQuery } = useApi(); + const { formatMessage, messages } = useMessages(); + const { startAt, endAt } = getEventDataDateRange(); + + const { data, isLoading } = useQuery>({ + queryKey: ['event-data:values', { websiteId, eventName, propertyName, startAt, endAt }], + queryFn: () => + get(`/websites/${websiteId}/event-data/values`, { + startAt, + endAt, + event: eventName, + propertyName, + }), + enabled: !!(websiteId && eventName && propertyName), + }); + + const values = data?.map(d => d.value) ?? []; + + return ( + { + onChange?.(v); + }} + formValue="text" + allowsEmptyCollection + allowsCustomValue + renderEmptyState={() => + isLoading ? ( + + ) : ( + + ) + } + > + {values.map(v => ( + + {v} + + ))} + + ); +} + +function OperatorSelect({ + value = 'eq', + onChange, +}: { + value?: string; + onChange?: (value: string) => void; +}) { + const { formatMessage, labels } = useMessages(); + return ( + + ); +} + +function StepRow({ + index, + websiteId, + initialEventName, + onRemove, +}: { + index: number; + websiteId: string; + initialEventName?: string; + onRemove: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const [eventName, setEventName] = useState(initialEventName ?? ''); + + return ( + + + + + + + + + + {({ field, context }) => { + const type = context.watch(`steps.${index}.type`); + return ( + { + setEventName(v); + }} + /> + ); + }} + + + + + + {({ fields: filterFields, append: appendFilter, remove: removeFilter, watch }) => { + const stepType = watch(`steps.${index}.type`); + + if (stepType !== 'event') return null; + + return ( + + {filterFields.map( + ( + { id: filterId, property: initialProperty }: { id: string; property?: string }, + filterIndex: number, + ) => ( + removeFilter(filterIndex)} + /> + ), + )} + + + + + ); + }} + + + ); +} + +function FilterRow({ + stepIndex, + filterIndex, + websiteId, + eventName, + initialProperty, + onRemove, +}: { + stepIndex: number; + filterIndex: number; + websiteId: string; + eventName: string; + initialProperty?: string; + onRemove: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const [propertyName, setPropertyName] = useState(initialProperty ?? ''); + + return ( + + + setPropertyName(v)} + /> + + + + + + + + + + ); +} + export function FunnelEditForm({ id, websiteId, @@ -35,7 +331,13 @@ export function FunnelEditForm({ const { data } = useReportQuery(id); const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); - const handleSubmit = async ({ name, ...parameters }) => { + const handleSubmit = async ({ + name, + ...parameters + }: { + name: string; + [key: string]: unknown; + }) => { await mutateAsync( { ...data, id, name, type: 'funnel', websiteId, parameters }, { @@ -56,7 +358,10 @@ export function FunnelEditForm({ const defaultValues = { name: data?.name || '', window: data?.parameters?.window || 60, - steps: data?.parameters?.steps || [{ type: 'path', value: '' }], + steps: (data?.parameters?.steps || [{ type: 'path', value: '' }]).map(step => ({ + ...step, + filters: step.filters || [], + })), }; return ( @@ -77,39 +382,23 @@ export function FunnelEditForm({ {({ fields, append, remove }) => { return ( - {fields.map(({ id }: { id: string }, index: number) => { - return ( - - - - - - - - - {({ field, context }) => { - const type = context.watch(`steps.${index}.type`); - return ; - }} - - - - - ); - })} + {fields.map( + ({ id, value: initialEventName }: { id: string; value: string }, index: number) => ( + + {index > 0 && } + remove(index)} + /> + + ), + )} +