Merge pull request #4090 from boutterudy/feat-funnel-per-step-filters
feat(funnels): add per-step event property filters
This commit is contained in:
@@ -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<string, any>;
|
||||
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<Array<FunnelResult>>(type, {
|
||||
websiteId,
|
||||
...parameters,
|
||||
});
|
||||
|
||||
const operatorLabels = useOperatorLabels();
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<Grid gap>
|
||||
@@ -54,7 +55,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||
</Grid>
|
||||
{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 }) {
|
||||
<Text color="muted">{t(labels.conversionRate)}</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
|
||||
<Text>{value}</Text>
|
||||
</Row>
|
||||
<Column gap="1">
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
|
||||
<Text>{value}</Text>
|
||||
</Row>
|
||||
{filters?.map((f, i) => (
|
||||
<Row key={i} gap="1" style={{ paddingLeft: 28 }}>
|
||||
<Text size="1" color="muted">
|
||||
{f.property}
|
||||
</Text>
|
||||
<Text size="1" color="muted" transform="lowercase">
|
||||
{operatorLabels[f.operator] ?? f.operator}
|
||||
</Text>
|
||||
<Text size="1" color="muted">
|
||||
{f.value}
|
||||
</Text>
|
||||
</Row>
|
||||
))}
|
||||
</Column>
|
||||
<Row alignItems="center" gap>
|
||||
{index > 0 && (
|
||||
<ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
|
||||
|
||||
@@ -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 (
|
||||
<ComboBox
|
||||
aria-label="PropertySelect"
|
||||
items={properties}
|
||||
inputValue={value}
|
||||
onInputChange={v => {
|
||||
setSearch(v);
|
||||
onChange?.(v);
|
||||
onPropertyChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={formatMessage(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{properties.map(p => (
|
||||
<ListItem key={p} id={p}>
|
||||
{p}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
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<Array<{ value: string; total: number }>>({
|
||||
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 (
|
||||
<ComboBox
|
||||
aria-label="ValueSelect"
|
||||
items={values}
|
||||
inputValue={value}
|
||||
onInputChange={v => {
|
||||
onChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={formatMessage(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{values.map(v => (
|
||||
<ListItem key={v} id={v}>
|
||||
{v}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorSelect({
|
||||
value = 'eq',
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
return (
|
||||
<Select value={value} onChange={onChange}>
|
||||
<ListItem id="eq">{formatMessage(labels.is)}</ListItem>
|
||||
<ListItem id="neq">{formatMessage(labels.isNot)}</ListItem>
|
||||
<ListItem id="c">{formatMessage(labels.contains)}</ListItem>
|
||||
<ListItem id="dnc">{formatMessage(labels.doesNotContain)}</ListItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
index,
|
||||
websiteId,
|
||||
initialEventName,
|
||||
onRemove,
|
||||
}: {
|
||||
index: number;
|
||||
websiteId: string;
|
||||
initialEventName?: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [eventName, setEventName] = useState(initialEventName ?? '');
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Grid columns="260px 1fr auto" gap>
|
||||
<Column>
|
||||
<FormField
|
||||
name={`steps.${index}.type`}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column>
|
||||
<FormField
|
||||
name={`steps.${index}.value`}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
{({ field, context }) => {
|
||||
const type = context.watch(`steps.${index}.type`);
|
||||
return (
|
||||
<LookupField
|
||||
websiteId={websiteId}
|
||||
type={type}
|
||||
{...field}
|
||||
onValueChange={(v: string) => {
|
||||
setEventName(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
<FormFieldArray name={`steps.${index}.filters`}>
|
||||
{({ fields: filterFields, append: appendFilter, remove: removeFilter, watch }) => {
|
||||
const stepType = watch(`steps.${index}.type`);
|
||||
|
||||
if (stepType !== 'event') return null;
|
||||
|
||||
return (
|
||||
<Grid gap>
|
||||
{filterFields.map(
|
||||
(
|
||||
{ id: filterId, property: initialProperty }: { id: string; property?: string },
|
||||
filterIndex: number,
|
||||
) => (
|
||||
<FilterRow
|
||||
key={filterId}
|
||||
stepIndex={index}
|
||||
filterIndex={filterIndex}
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
initialProperty={initialProperty}
|
||||
onRemove={() => removeFilter(filterIndex)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Row>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => appendFilter({ property: '', operator: 'eq', value: '' })}
|
||||
>
|
||||
<Icon size="sm">
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.filter)}</Text>
|
||||
</Button>
|
||||
</Row>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</FormFieldArray>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Grid columns="1fr 140px 1fr auto" gap>
|
||||
<FormField
|
||||
name={`steps.${stepIndex}.filters.${filterIndex}.property`}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<PropertySelect
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
onPropertyChange={v => setPropertyName(v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.operator`}>
|
||||
<OperatorSelect />
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.value`}>
|
||||
<ValueSelect websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
|
||||
</FormField>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Grid gap>
|
||||
{fields.map(({ id }: { id: string }, index: number) => {
|
||||
return (
|
||||
<Grid key={id} columns="260px 1fr auto" gap>
|
||||
<Column>
|
||||
<FormField
|
||||
name={`steps.${index}.type`}
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column>
|
||||
<FormField
|
||||
name={`steps.${index}.value`}
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
{({ field, context }) => {
|
||||
const type = context.watch(`steps.${index}.type`);
|
||||
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
<Button onPress={() => remove(index)}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
{fields.map(
|
||||
({ id, value: initialEventName }: { id: string; value: string }, index: number) => (
|
||||
<Fragment key={id}>
|
||||
{index > 0 && <ListSeparator />}
|
||||
<StepRow
|
||||
index={index}
|
||||
websiteId={websiteId}
|
||||
initialEventName={initialEventName}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
<ListSeparator />
|
||||
<Row>
|
||||
<Button
|
||||
onPress={() => append({ type: 'path', value: '' })}
|
||||
onPress={() => append({ type: 'path', value: '', filters: [] })}
|
||||
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
|
||||
>
|
||||
<Icon>
|
||||
|
||||
@@ -91,6 +91,7 @@ export * from './useMessages';
|
||||
export * from './useMobile';
|
||||
export * from './useModified';
|
||||
export * from './useNavigation';
|
||||
export * from './useOperatorLabels';
|
||||
export * from './usePagedQuery';
|
||||
export * from './usePageParameters';
|
||||
export * from './useRegionNames';
|
||||
|
||||
@@ -3,11 +3,13 @@ import { safeDecodeURIComponent } from '@/lib/url';
|
||||
import { useFields } from './useFields';
|
||||
import { useMessages } from './useMessages';
|
||||
import { useNavigation } from './useNavigation';
|
||||
import { useOperatorLabels } from './useOperatorLabels';
|
||||
|
||||
export function useFilters() {
|
||||
const { t, labels } = useMessages();
|
||||
const { query } = useNavigation();
|
||||
const { fields } = useFields();
|
||||
const operatorLabels = useOperatorLabels();
|
||||
|
||||
const operators = [
|
||||
{ name: 'eq', type: 'string', label: t(labels.is) },
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { OPERATORS } from '@/lib/constants';
|
||||
import { useMessages } from './useMessages';
|
||||
|
||||
export function useOperatorLabels(): Record<string, string> {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return {
|
||||
[OPERATORS.equals]: formatMessage(labels.is),
|
||||
[OPERATORS.notEquals]: formatMessage(labels.isNot),
|
||||
[OPERATORS.set]: formatMessage(labels.isSet),
|
||||
[OPERATORS.notSet]: formatMessage(labels.isNotSet),
|
||||
[OPERATORS.contains]: formatMessage(labels.contains),
|
||||
[OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain),
|
||||
[OPERATORS.true]: formatMessage(labels.true),
|
||||
[OPERATORS.false]: formatMessage(labels.false),
|
||||
[OPERATORS.greaterThan]: formatMessage(labels.greaterThan),
|
||||
[OPERATORS.lessThan]: formatMessage(labels.lessThan),
|
||||
[OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals),
|
||||
[OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals),
|
||||
[OPERATORS.before]: formatMessage(labels.before),
|
||||
[OPERATORS.after]: formatMessage(labels.after),
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,14 @@ export interface LookupFieldProps extends ComboBoxProps {
|
||||
type: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* `FormField` injects `onChange` via `cloneElement`, overwriting any custom handler.
|
||||
* Use `onValueChange` for side effects that must survive that override.
|
||||
*/
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
|
||||
export function LookupField({ websiteId, type, value, onChange, onValueChange, ...props }: LookupFieldProps) {
|
||||
const { t, messages } = useMessages();
|
||||
const [search, setSearch] = useState(value);
|
||||
const searchValue = useDebounce(search, 300);
|
||||
@@ -43,6 +48,7 @@ export function LookupField({ websiteId, type, value, onChange, ...props }: Look
|
||||
onInputChange={value => {
|
||||
handleSearch(value);
|
||||
onChange?.(value);
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
|
||||
@@ -179,6 +179,15 @@ export const funnelReportSchema = z.object({
|
||||
z.object({
|
||||
type: z.enum(['path', 'event']),
|
||||
value: z.string(),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
property: z.string().min(1),
|
||||
operator: z.enum(['eq', 'neq', 'c', 'dnc']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.min(2)
|
||||
|
||||
@@ -3,17 +3,31 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
export interface FunnelStepFilter {
|
||||
property: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FunnelStep {
|
||||
type: string;
|
||||
value: string;
|
||||
filters?: Array<FunnelStepFilter>;
|
||||
}
|
||||
|
||||
export interface FunnelParameters {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
window: number;
|
||||
steps: { type: string; value: string }[];
|
||||
steps: Array<FunnelStep>;
|
||||
}
|
||||
|
||||
export interface FunnelResult {
|
||||
value: string;
|
||||
export interface FunnelResult extends FunnelStep {
|
||||
visitors: number;
|
||||
previous: number;
|
||||
dropped: number;
|
||||
dropoff: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export async function getFunnel(
|
||||
@@ -29,7 +43,7 @@ async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: FunnelParameters,
|
||||
filters: QueryFilters,
|
||||
): Promise<FunnelResult[]> {
|
||||
): Promise<Array<FunnelResult>> {
|
||||
const { startDate, endDate, window, steps } = parameters;
|
||||
const { rawQuery, getAddIntervalQuery, parseFilters } = prisma;
|
||||
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||
@@ -40,16 +54,55 @@ async function relationalQuery(
|
||||
});
|
||||
const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, window);
|
||||
|
||||
function buildExistsFilters(
|
||||
stepIndex: number,
|
||||
stepFilters: Array<FunnelStepFilter> | undefined,
|
||||
eventAlias: string,
|
||||
extraParams: Record<string, string>,
|
||||
): string {
|
||||
if (!stepFilters?.length) return '';
|
||||
|
||||
return stepFilters
|
||||
.map((f, fi) => {
|
||||
const keyParam = `f_${stepIndex}_${fi}_k`;
|
||||
const valParam = `f_${stepIndex}_${fi}_v`;
|
||||
extraParams[keyParam] = f.property;
|
||||
|
||||
let op = '=';
|
||||
let val = f.value;
|
||||
if (f.operator === 'neq') op = '!=';
|
||||
else if (f.operator === 'c') {
|
||||
op = 'ilike';
|
||||
val = `%${val}%`;
|
||||
} else if (f.operator === 'dnc') {
|
||||
op = 'not ilike';
|
||||
val = `%${val}%`;
|
||||
}
|
||||
extraParams[valParam] = val;
|
||||
|
||||
return `and exists (
|
||||
select 1 from event_data _ed${stepIndex}_${fi}
|
||||
where _ed${stepIndex}_${fi}.website_event_id = ${eventAlias}.event_id
|
||||
and _ed${stepIndex}_${fi}.website_id = {{websiteId::uuid}}
|
||||
and _ed${stepIndex}_${fi}.data_key = {{${keyParam}}}
|
||||
and case when _ed${stepIndex}_${fi}.data_type = 2 then replace(_ed${stepIndex}_${fi}.string_value, '.0000', '') else _ed${stepIndex}_${fi}.string_value end ${op} {{${valParam}}}
|
||||
)`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function getFunnelQuery(
|
||||
steps: { type: string; value: string }[],
|
||||
steps: Array<FunnelStep>,
|
||||
window: number,
|
||||
): {
|
||||
levelOneQuery: string;
|
||||
levelQuery: string;
|
||||
sumQuery: string;
|
||||
params: string[];
|
||||
params: Record<string, string>;
|
||||
} {
|
||||
return steps.reduce(
|
||||
const extraParams: Record<string, string> = {};
|
||||
|
||||
const result = steps.reduce(
|
||||
(pv, cv, i) => {
|
||||
const levelNumber = i + 1;
|
||||
const startSum = i > 0 ? 'union ' : '';
|
||||
@@ -64,6 +117,16 @@ async function relationalQuery(
|
||||
paramValue = cv.value.replace(/^\*|\*$/g, '%');
|
||||
}
|
||||
|
||||
const existsClause =
|
||||
!isURL && cv.filters?.length
|
||||
? buildExistsFilters(
|
||||
i,
|
||||
cv.filters,
|
||||
levelNumber === 1 ? 'website_event' : 'we',
|
||||
extraParams,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (levelNumber === 1) {
|
||||
pv.levelOneQuery = `
|
||||
WITH level1 AS (
|
||||
@@ -75,6 +138,7 @@ async function relationalQuery(
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and ${column} ${operator} {{${i}}}
|
||||
${filterQuery}
|
||||
${existsClause}
|
||||
)`;
|
||||
} else {
|
||||
pv.levelQuery += `
|
||||
@@ -90,11 +154,12 @@ async function relationalQuery(
|
||||
)}
|
||||
and we.${column} ${operator} {{${i}}}
|
||||
and we.created_at <= {{endDate}}
|
||||
${existsClause}
|
||||
)`;
|
||||
}
|
||||
|
||||
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
|
||||
pv.params.push(paramValue);
|
||||
pv.params[i] = paramValue;
|
||||
|
||||
return pv;
|
||||
},
|
||||
@@ -102,9 +167,11 @@ async function relationalQuery(
|
||||
levelOneQuery: '',
|
||||
levelQuery: '',
|
||||
sumQuery: '',
|
||||
params: [],
|
||||
params: {} as Record<string, string>,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...result, params: { ...result.params, ...extraParams } };
|
||||
}
|
||||
|
||||
return rawQuery(
|
||||
@@ -125,13 +192,7 @@ async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
parameters: FunnelParameters,
|
||||
filters: QueryFilters,
|
||||
): Promise<
|
||||
{
|
||||
value: string;
|
||||
visitors: number;
|
||||
dropoff: number;
|
||||
}[]
|
||||
> {
|
||||
): Promise<Array<FunnelResult>> {
|
||||
const { startDate, endDate, window, steps } = parameters;
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
|
||||
@@ -145,8 +206,43 @@ async function clickhouseQuery(
|
||||
endDate,
|
||||
});
|
||||
|
||||
function buildEventDataFilters(
|
||||
stepIndex: number,
|
||||
stepFilters: Array<FunnelStepFilter> | undefined,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
if (!stepFilters?.length) return '';
|
||||
|
||||
return stepFilters
|
||||
.map((f, fi) => {
|
||||
const keyParam = `f_${stepIndex}_${fi}_k`;
|
||||
const valParam = `f_${stepIndex}_${fi}_v`;
|
||||
params[keyParam] = f.property;
|
||||
|
||||
let op = '=';
|
||||
let val = f.value;
|
||||
if (f.operator === 'neq') op = '!=';
|
||||
else if (f.operator === 'c') {
|
||||
op = 'like';
|
||||
val = `%${val}%`;
|
||||
} else if (f.operator === 'dnc') {
|
||||
op = 'not like';
|
||||
val = `%${val}%`;
|
||||
}
|
||||
params[valParam] = val;
|
||||
|
||||
return `and event_id in (
|
||||
select event_id from event_data
|
||||
where website_id = {websiteId:UUID}
|
||||
and data_key = {${keyParam}:String}
|
||||
and multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), string_value) ${op} {${valParam}:String}
|
||||
)`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function getFunnelQuery(
|
||||
steps: { type: string; value: string }[],
|
||||
steps: Array<FunnelStep>,
|
||||
window: number,
|
||||
): {
|
||||
levelOneQuery: string;
|
||||
@@ -155,7 +251,9 @@ async function clickhouseQuery(
|
||||
stepFilterQuery: string;
|
||||
params: Record<string, string>;
|
||||
} {
|
||||
return steps.reduce(
|
||||
const extraParams: Record<string, string> = {};
|
||||
|
||||
const result = steps.reduce(
|
||||
(pv, cv, i) => {
|
||||
const levelNumber = i + 1;
|
||||
const startSum = i > 0 ? 'union all ' : '';
|
||||
@@ -171,12 +269,16 @@ async function clickhouseQuery(
|
||||
paramValue = cv.value.replace(/^\*|\*$/g, '%');
|
||||
}
|
||||
|
||||
const eventDataClause =
|
||||
!isURL && cv.filters?.length ? buildEventDataFilters(i, cv.filters, extraParams) : '';
|
||||
|
||||
if (levelNumber === 1) {
|
||||
pv.levelOneQuery = `\n
|
||||
level1 AS (
|
||||
select *
|
||||
from level0
|
||||
where ${column} ${operator} {param${i}:String}
|
||||
${eventDataClause}
|
||||
)`;
|
||||
} else {
|
||||
pv.levelQuery += `\n
|
||||
@@ -184,13 +286,15 @@ async function clickhouseQuery(
|
||||
select distinct y.session_id as session_id,
|
||||
y.url_path as url_path,
|
||||
y.referrer_path as referrer_path,
|
||||
y.event_name as event_name,
|
||||
y.event_name,
|
||||
y.event_id,
|
||||
y.created_at as created_at
|
||||
from level${i} x
|
||||
join level0 y
|
||||
on x.session_id = y.session_id
|
||||
where y.created_at between x.created_at and x.created_at + interval ${window} minute
|
||||
and y.${column} ${operator} {param${i}:String}
|
||||
${eventDataClause}
|
||||
)`;
|
||||
}
|
||||
|
||||
@@ -205,15 +309,17 @@ async function clickhouseQuery(
|
||||
levelQuery: '',
|
||||
sumQuery: '',
|
||||
stepFilterQuery: '',
|
||||
params: {},
|
||||
params: {} as Record<string, string>,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...result, params: { ...result.params, ...extraParams } };
|
||||
}
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
WITH level0 AS (
|
||||
select distinct session_id, url_path, referrer_path, event_name, created_at
|
||||
select distinct event_id, session_id, url_path, referrer_path, event_name, created_at
|
||||
from website_event
|
||||
${cohortQuery}
|
||||
where (${stepFilterQuery})
|
||||
@@ -225,7 +331,7 @@ async function clickhouseQuery(
|
||||
${levelQuery}
|
||||
select *
|
||||
from (
|
||||
${sumQuery}
|
||||
${sumQuery}
|
||||
) ORDER BY level;
|
||||
`,
|
||||
{
|
||||
@@ -235,8 +341,8 @@ async function clickhouseQuery(
|
||||
).then(formatResults(steps));
|
||||
}
|
||||
|
||||
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
|
||||
return steps.map((step: { type: string; value: string }, i: number) => {
|
||||
const formatResults = (steps: Array<FunnelStep>) => (results: unknown) => {
|
||||
return steps.map((step: FunnelStep, i: number) => {
|
||||
const visitors = Number(results[i]?.count) || 0;
|
||||
const previous = Number(results[i - 1]?.count) || 0;
|
||||
const dropped = previous > 0 ? previous - visitors : 0;
|
||||
|
||||
Reference in New Issue
Block a user