Merge branch 'dev' into analytics

# Conflicts:
#	src/app/(main)/reports/funnel/FunnelParameters.tsx
This commit is contained in:
Mike Cao 2024-04-03 00:04:33 -07:00
commit 1b8923fef7
31 changed files with 369 additions and 265 deletions

View File

@ -16,7 +16,7 @@ import {
} from 'react-basics'; } from 'react-basics';
import { useMessages, useFilters, useFormat, useLocale, useWebsiteValues } from 'components/hooks'; import { useMessages, useFilters, useFormat, useLocale, useWebsiteValues } from 'components/hooks';
import { OPERATORS } from 'lib/constants'; import { OPERATORS } from 'lib/constants';
import { operatorEquals } from 'lib/params'; import { isEqualsOperator } from 'lib/params';
import styles from './FieldFilterEditForm.module.css'; import styles from './FieldFilterEditForm.module.css';
export interface FieldFilterFormProps { export interface FieldFilterFormProps {
@ -50,7 +50,7 @@ export default function FieldFilterEditForm({
const [operator, setOperator] = useState(defaultOperator); const [operator, setOperator] = useState(defaultOperator);
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const isEquals = operatorEquals(operator); const isEquals = isEqualsOperator(operator);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selected, setSelected] = useState(isEquals ? value : ''); const [selected, setSelected] = useState(isEquals ? value : '');
const { filters } = useFilters(); const { filters } = useFilters();

View File

@ -7,7 +7,7 @@ import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from './Report'; import { ReportContext } from './Report';
import FieldFilterEditForm from '../[reportId]/FieldFilterEditForm'; import FieldFilterEditForm from '../[reportId]/FieldFilterEditForm';
import { operatorEquals } from 'lib/params'; import { isSearchOperator } from 'lib/params';
import styles from './FilterParameters.module.css'; import styles from './FilterParameters.module.css';
export function FilterParameters() { export function FilterParameters() {
@ -69,7 +69,7 @@ export function FilterParameters() {
{filters.map( {filters.map(
({ name, operator, value }: { name: string; operator: string; value: string }) => { ({ name, operator, value }: { name: string; operator: string; value: string }) => {
const label = fields.find(f => f.name === name)?.label; const label = fields.find(f => f.name === name)?.label;
const isEquals = operatorEquals(operator); const isSearch = isSearchOperator(operator);
return ( return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}> <ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
@ -79,7 +79,7 @@ export function FilterParameters() {
name={name} name={name}
label={label} label={label}
operator={operator} operator={operator}
value={isEquals ? formatValue(value, name) : value} value={isSearch ? value : formatValue(value, name)}
onChange={handleChange} onChange={handleChange}
/> />
</ParameterList.Item> </ParameterList.Item>

View File

@ -13,7 +13,7 @@ export function Report({
className, className,
}: { }: {
reportId: string; reportId: string;
defaultParameters: { [key: string]: any }; defaultParameters: { type: string; parameters: { [key: string]: any } };
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {

View File

@ -37,12 +37,12 @@
.card { .card {
display: grid; display: grid;
gap: 20px; gap: 20px;
margin-top: 8px;
} }
.header { .header {
display: flex; display: flex;
align-items: center; flex-direction: column;
font-weight: 700;
gap: 20px; gap: 20px;
} }
@ -51,19 +51,16 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background: var(--base900); background: var(--base900);
height: 50px; height: 30px;
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.label { .label {
color: var(--base700); color: var(--base600);
} font-weight: 700;
text-transform: uppercase;
.value {
color: var(--base50);
margin-inline-end: 20px;
} }
.track { .track {
@ -72,13 +69,33 @@
} }
.info { .info {
display: flex;
justify-content: space-between;
text-transform: lowercase; text-transform: lowercase;
} }
.item { .item {
padding: 6px 10px; font-size: 24px;
border-radius: 4px; color: var(--base900);
border: 1px solid var(--base300); font-weight: 700;
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 24px;
margin: 10px 0;
text-transform: lowercase;
}
.visitors {
color: var(--base900);
font-size: 32px;
font-weight: 900;
margin-right: 10px;
}
.percent {
font-weight: 700;
align-self: flex-end;
} }

View File

@ -2,8 +2,8 @@ import { useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps { export interface FunnelChartProps {
className?: string; className?: string;
@ -18,37 +18,35 @@ export function FunnelChart({ className }: FunnelChartProps) {
return ( return (
<div className={classNames(styles.chart, className)}> <div className={classNames(styles.chart, className)}>
{data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => { {data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => {
return ( return (
<div key={url} className={styles.step}> <div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div> <div className={styles.num}>{index + 1}</div>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span> <span className={styles.label}>
<span className={styles.item}>{url}</span> {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(visitors)}</span>
{formatMessage(labels.visitors)}
</div>
<div className={styles.percent}>{(remaining * 100).toFixed(2)}%</div>
</div> </div>
<div className={styles.track}> <div className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}> <div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
</div>
<div className={styles.info}>
<div>
<b>{formatLongNumber(visitors)}</b>
<span> {formatMessage(labels.visitors)}</span>
<span> ({(remaining * 100).toFixed(2)}%)</span>
</div> </div>
{dropoff > 0 && ( {dropoff > 0 && (
<div> <div className={styles.info}>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} ( <b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%) {(dropoff * 100).toFixed(2)}%)
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>

View File

@ -0,0 +1,9 @@
.item {
display: flex;
align-items: center;
gap: 10px;
}
.type {
color: var(--base700);
}

View File

@ -10,48 +10,54 @@ import {
Popup, Popup,
SubmitButton, SubmitButton,
TextField, TextField,
Button,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm'; import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() { export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {}; const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2; const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => { const handleSubmit = (data: any, e: any) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!queryDisabled) { if (!queryDisabled) {
runReport(data); runReport(data);
} }
}; };
const handleAddUrl = (url: string) => { const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } }); updateReport({ parameters: { steps: parameters.steps.concat(step) } });
}; };
const handleRemoveUrl = (url: string) => { const handleRemoveStep = (index: number) => {
const urls = [...parameters.urls]; const steps = [...parameters.steps];
updateReport({ parameters: { urls: urls.filter(n => n !== url) } }); delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
}; };
const AddUrlButton = () => { const AddStepButton = () => {
return ( return (
<PopupTrigger> <PopupTrigger>
<Button>
<Icon> <Icon>
<Icons.Plus /> <Icons.Plus />
</Icon> </Icon>
<Popup position="right" alignment="start"> </Button>
<Popup alignment="start">
<PopupForm> <PopupForm>
<UrlAddForm onAdd={handleAddUrl} /> <FunnelStepAddForm onAdd={handleAddStep} />
</PopupForm> </PopupForm>
</Popup> </Popup>
</PopupTrigger> </PopupTrigger>
@ -69,12 +75,17 @@ export function FunnelParameters() {
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}> <FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList> <ParameterList>
{urls.map(url => { {steps.map((step: { type: string; value: string }, index: number) => {
return ( return (
<ParameterList.Item key={url} onRemove={() => handleRemoveUrl(url)}> <ParameterList.Item key={index} onRemove={() => handleRemoveStep(index)}>
{url} <div className={styles.item}>
<div className={styles.type}>
<Icon>{step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
</div>
<div>{step.value}</div>
</div>
</ParameterList.Item> </ParameterList.Item>
); );
})} })}

View File

@ -9,7 +9,7 @@ import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = { const defaultParameters = {
type: REPORT_TYPES.funnel, type: REPORT_TYPES.funnel,
parameters: { window: 60, urls: [] }, parameters: { window: 60, steps: [] },
}; };
export default function FunnelReport({ reportId }: { reportId?: string }) { export default function FunnelReport({ reportId }: { reportId?: string }) {

View File

@ -0,0 +1,7 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, FormRow, TextField, Flexbox, Dropdown, Item } from 'react-basics';
import styles from './FunnelStepAddForm.module.css';
export interface UrlAddFormProps {
defaultValue?: string;
onAdd?: (step: { type: string; value: string }) => void;
}
export function FunnelStepAddForm({ defaultValue = '', onAdd }: UrlAddFormProps) {
const [type, setType] = useState('url');
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onAdd({ type, value });
setValue('');
};
const handleChange = e => {
setValue(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
const renderTypeValue = (value: any) => {
return items.find(item => item.value === value)?.label;
};
return (
<FormRow label={formatMessage(labels.addStep)}>
<Flexbox gap={10}>
<Dropdown
className={styles.dropdown}
items={items}
value={type}
renderValue={renderTypeValue}
onChange={(value: any) => setType(value)}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
<TextField
className={styles.input}
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
);
}
export default FunnelStepAddForm;

View File

@ -1,14 +0,0 @@
.form {
position: absolute;
background: var(--base50);
width: 300px;
padding: 30px;
margin-top: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}
.input {
width: 100%;
}

View File

@ -1,52 +0,0 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
import styles from './UrlAddForm.module.css';
export interface UrlAddFormProps {
defaultValue?: string;
onAdd?: (url: string) => void;
}
export function UrlAddForm({ defaultValue = '', onAdd }: UrlAddFormProps) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onAdd(url);
setUrl('');
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<Flexbox gap={10}>
<TextField
className={styles.input}
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
</Form>
);
}
export default UrlAddForm;

View File

@ -44,7 +44,6 @@ export default function WebsiteExpandedView({
const { const {
router, router,
renderUrl, renderUrl,
pathname,
query: { view }, query: { view },
} = useNavigation(); } = useNavigation();
@ -122,7 +121,12 @@ export default function WebsiteExpandedView({
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
<div className={styles.menu}> <div className={styles.menu}>
<LinkButton href={pathname} className={styles.back} variant="quiet" scroll={false}> <LinkButton
href={renderUrl({ view: undefined })}
className={styles.back}
variant="quiet"
scroll={false}
>
<Icon rotate={dir === 'rtl' ? 0 : 180}> <Icon rotate={dir === 'rtl' ? 0 : 180}>
<Icons.ArrowRight /> <Icons.ArrowRight />
</Icon> </Icon>

View File

@ -81,6 +81,7 @@ export function Chart({
const updateChart = (data: any) => { const updateChart = (data: any) => {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
dataset.data = data?.datasets[index]?.data; dataset.data = data?.datasets[index]?.data;
chart.current.legend.legendItems[index].text = data?.datasets[index].label;
}); });
chart.current.options = options; chart.current.options = options;
@ -88,9 +89,9 @@ export function Chart({
// Allow config changes before update // Allow config changes before update
onUpdate?.(chart.current); onUpdate?.(chart.current);
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems); setLegendItems(chart.current.legend.legendItems);
chart.current.update(updateMode);
}; };
useEffect(() => { useEffect(() => {

View File

@ -4,7 +4,10 @@ import { useApi } from './useApi';
import { useTimezone } from '../useTimezone'; import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages'; import { useMessages } from '../useMessages';
export function useReport(reportId: string, defaultParameters: { [key: string]: any } = {}) { export function useReport(
reportId: string,
defaultParameters: { type: string; parameters: { [key: string]: any } },
) {
const [report, setReport] = useState(null); const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi(); const { get, post } = useApi();
@ -28,6 +31,8 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
dateRange.endDate = new Date(endDate); dateRange.endDate = new Date(endDate);
} }
data.parameters = { ...defaultParameters?.parameters, ...data.parameters };
setReport(data); setReport(data);
}; };
@ -41,7 +46,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
setReport( setReport(
produce((state: any) => { produce((state: any) => {
state.parameters = parameters; state.parameters = { ...defaultParameters?.parameters, ...parameters };
state.data = data; state.data = data;
return state; return state;
@ -60,7 +65,11 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
const { parameters, ...rest } = data; const { parameters, ...rest } = data;
if (parameters) { if (parameters) {
state.parameters = { ...state.parameters, ...parameters }; state.parameters = {
...defaultParameters?.parameters,
...state.parameters,
...parameters,
};
} }
for (const key in rest) { for (const key in rest) {
@ -80,7 +89,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]:
} else { } else {
loadReport(reportId); loadReport(reportId);
} }
}, []); }, [reportId]);
return { report, runReport, updateReport, isRunning }; return { report, runReport, updateReport, isRunning };
} }

View File

@ -232,6 +232,8 @@ export const labels = defineMessages({
id: 'label.utm-description', id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.', defaultMessage: 'Track your campaigns through UTM parameters.',
}, },
steps: { id: 'label.steps', defaultMessage: 'Steps' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View File

@ -11,7 +11,7 @@ import {
import PopupForm from 'app/(main)/reports/[reportId]/PopupForm'; import PopupForm from 'app/(main)/reports/[reportId]/PopupForm';
import FieldFilterEditForm from 'app/(main)/reports/[reportId]/FieldFilterEditForm'; import FieldFilterEditForm from 'app/(main)/reports/[reportId]/FieldFilterEditForm';
import { OPERATOR_PREFIXES } from 'lib/constants'; import { OPERATOR_PREFIXES } from 'lib/constants';
import { operatorEquals, parseParameterValue } from 'lib/params'; import { isSearchOperator, parseParameterValue } from 'lib/params';
import styles from './FilterTags.module.css'; import styles from './FilterTags.module.css';
export function FilterTags({ export function FilterTags({
@ -66,7 +66,7 @@ export function FilterTags({
} }
const label = fields.find(f => f.name === key)?.label; const label = fields.find(f => f.name === key)?.label;
const { operator, value } = parseParameterValue(params[key]); const { operator, value } = parseParameterValue(params[key]);
const paramValue = operatorEquals(operator) ? formatValue(value, key) : value; const paramValue = isSearchOperator(operator) ? formatValue(value, key) : value;
return ( return (
<PopupTrigger key={key}> <PopupTrigger key={key}>

View File

@ -38,7 +38,7 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
}, },
], ],
}; };
}, [data]); }, [data, locale]);
return ( return (
<BarChart <BarChart

View File

@ -3,9 +3,10 @@ import dateFormat from 'dateformat';
import debug from 'debug'; import debug from 'debug';
import { CLICKHOUSE } from 'lib/db'; import { CLICKHOUSE } from 'lib/db';
import { QueryFilters, QueryOptions } from './types'; import { QueryFilters, QueryOptions } from './types';
import { FILTER_COLUMNS, OPERATORS } from './constants'; import { OPERATORS } from './constants';
import { loadWebsite } from './load'; import { loadWebsite } from './load';
import { maxDate } from './date'; import { maxDate } from './date';
import { filtersToArray } from './params';
export const CLICKHOUSE_DATE_FORMATS = { export const CLICKHOUSE_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%M:00', minute: '%Y-%m-%d %H:%M:00',
@ -79,15 +80,11 @@ function mapFilter(column: string, operator: string, name: string, type: string
} }
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) { function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = Object.keys(filters).reduce((arr, key) => { const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
const filter = filters[key]; if (column) {
const operator = filter?.operator ?? OPERATORS.equals; arr.push(`and ${mapFilter(column, operator, name)}`);
const column = filter?.column ?? FILTER_COLUMNS[key] ?? options?.columns?.[key];
if (filter !== undefined && column !== undefined) { if (name === 'referrer') {
arr.push(`and ${mapFilter(column, operator, key)}`);
if (key === 'referrer') {
arr.push('and referrer_domain != {websiteDomain:String}'); arr.push('and referrer_domain != {websiteDomain:String}');
} }
} }
@ -98,11 +95,11 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {})
return query.join('\n'); return query.join('\n');
} }
function normalizeFilters(filters = {}) { function getFilterParams(filters: QueryFilters = {}) {
return Object.keys(filters).reduce((obj, key) => { return filtersToArray(filters).reduce((obj, { name, value }) => {
const value = filters[key]; if (name && value !== undefined) {
obj[name] = value;
obj[key] = value?.value ?? value; }
return obj; return obj;
}, {}); }, {});
@ -114,7 +111,7 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
return { return {
filterQuery: getFilterQuery(filters, options), filterQuery: getFilterQuery(filters, options),
params: { params: {
...normalizeFilters(filters), ...getFilterParams(filters),
websiteId, websiteId,
startDate: maxDate(filters.startDate, new Date(website?.resetAt)), startDate: maxDate(filters.startDate, new Date(website?.resetAt)),
websiteDomain: website.domain, websiteDomain: website.domain,

View File

@ -1,6 +1,8 @@
import { OPERATOR_PREFIXES, OPERATORS } from 'lib/constants'; import { FILTER_COLUMNS, OPERATOR_PREFIXES, OPERATORS } from 'lib/constants';
import { QueryFilters, QueryOptions } from 'lib/types';
export function parseParameterValue(param: string) { export function parseParameterValue(param: any) {
if (typeof param === 'string') {
const [, prefix, value] = param.match(/^(!~|!|~)?(.*)$/); const [, prefix, value] = param.match(/^(!~|!|~)?(.*)$/);
const operator = const operator =
@ -9,7 +11,36 @@ export function parseParameterValue(param: string) {
return { operator, value }; return { operator, value };
} }
return { operator: OPERATORS.equals, value: param };
}
export function operatorEquals(operator: any) { export function isEqualsOperator(operator: any) {
return [OPERATORS.equals, OPERATORS.notEquals].includes(operator); return [OPERATORS.equals, OPERATORS.notEquals].includes(operator);
} }
export function isSearchOperator(operator: any) {
return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator);
}
export function filtersToArray(filters: QueryFilters = {}, options: QueryOptions = {}) {
return Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === null) {
return arr;
}
if (filter?.name && filter?.value !== undefined) {
return arr.concat(filter);
}
const { operator, value } = parseParameterValue(filter);
return arr.concat({
name: key,
column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
operator,
value,
});
}, []);
}

View File

@ -2,10 +2,11 @@ import { Prisma } from '@prisma/client';
import prisma from '@umami/prisma-client'; import prisma from '@umami/prisma-client';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants'; import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
import { loadWebsite } from './load'; import { loadWebsite } from './load';
import { maxDate } from './date'; import { maxDate } from './date';
import { QueryFilters, QueryOptions, SearchFilter } from './types'; import { QueryFilters, QueryOptions, SearchFilter } from './types';
import { filtersToArray } from './params';
const MYSQL_DATE_FORMATS = { const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00', minute: '%Y-%m-%d %H:%i:00',
@ -112,15 +113,11 @@ function mapFilter(column: string, operator: string, name: string, type: string
} }
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string { function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string {
const query = Object.keys(filters).reduce((arr, key) => { const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
const filter = filters[key]; if (column) {
const operator = filter?.operator ?? OPERATORS.equals; arr.push(`and ${mapFilter(column, operator, name)}`);
const column = filter?.column ?? FILTER_COLUMNS[key] ?? options?.columns?.[key];
if (filter !== undefined && column !== undefined) { if (name === 'referrer') {
arr.push(`and ${mapFilter(column, operator, key)}`);
if (key === 'referrer') {
arr.push( arr.push(
'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)',
); );
@ -133,12 +130,9 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}):
return query.join('\n'); return query.join('\n');
} }
function normalizeFilters(filters = {}) { function getFilterParams(filters: QueryFilters = {}) {
return Object.keys(filters).reduce((obj, key) => { return filtersToArray(filters).reduce((obj, { name, operator, value }) => {
const filter = filters[key]; obj[name] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator)
const value = filter?.value ?? filter;
obj[key] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(filter?.operator)
? `%${value}%` ? `%${value}%`
: value; : value;
@ -152,15 +146,16 @@ async function parseFilters(
options: QueryOptions = {}, options: QueryOptions = {},
) { ) {
const website = await loadWebsite(websiteId); const website = await loadWebsite(websiteId);
const joinSession = Object.keys(filters).find(key => SESSION_COLUMNS.includes(key));
return { return {
joinSession: joinSession:
options?.joinSession || Object.keys(filters).find(key => SESSION_COLUMNS.includes(key)) options?.joinSession || joinSession
? `inner join session on website_event.session_id = session.session_id` ? `inner join session on website_event.session_id = session.session_id`
: '', : '',
filterQuery: getFilterQuery(filters, options), filterQuery: getFilterQuery(filters, options),
params: { params: {
...normalizeFilters(filters), ...getFilterParams(filters),
websiteId, websiteId,
startDate: maxDate(filters.startDate, website?.resetAt), startDate: maxDate(filters.startDate, website?.resetAt),
websiteDomain: website.domain, websiteDomain: website.domain,

View File

@ -1,9 +1,9 @@
import { NextApiRequest } from 'next'; import { NextApiRequest } from 'next';
import { getAllowedUnits, getMinimumUnit } from './date'; import { getAllowedUnits, getMinimumUnit } from './date';
import { getWebsiteDateRange } from '../queries'; import { getWebsiteDateRange } from '../queries';
import { FILTER_COLUMNS, OPERATORS, OPERATOR_PREFIXES } from 'lib/constants'; import { FILTER_COLUMNS } from 'lib/constants';
export async function parseDateRangeQuery(req: NextApiRequest) { export async function getRequestDateRange(req: NextApiRequest) {
const { websiteId, startAt, endAt, unit } = req.query; const { websiteId, startAt, endAt, unit } = req.query;
// All-time // All-time
@ -31,27 +31,14 @@ export async function parseDateRangeQuery(req: NextApiRequest) {
}; };
} }
export function getQueryFilters(req: NextApiRequest) { export function getRequestFilters(req: NextApiRequest) {
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => { return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
const value = req.query[key]; const value = req.query[key];
if (value) { if (value !== undefined) {
obj[key] = value; obj[key] = value;
} }
if (typeof value === 'string') {
const [, prefix, paramValue] = value.match(/^(!~|!|~)?(.*)$/);
if (prefix && paramValue) {
obj[key] = {
name: key,
column: FILTER_COLUMNS[key],
operator: OPERATOR_PREFIXES[prefix] || OPERATORS.equals,
value: paramValue,
};
}
}
return obj; return obj;
}, {}); }, {});
} }

View File

@ -8,7 +8,7 @@ import * as yup from 'yup';
export interface FunnelRequestBody { export interface FunnelRequestBody {
websiteId: string; websiteId: string;
urls: string[]; steps: { type: string; value: string }[];
window: number; window: number;
dateRange: { dateRange: {
startDate: string; startDate: string;
@ -17,7 +17,7 @@ export interface FunnelRequestBody {
} }
export interface FunnelResponse { export interface FunnelResponse {
urls: string[]; steps: { type: string; value: string }[];
window: number; window: number;
startAt: number; startAt: number;
endAt: number; endAt: number;
@ -26,7 +26,16 @@ export interface FunnelResponse {
const schema = { const schema = {
POST: yup.object().shape({ POST: yup.object().shape({
websiteId: yup.string().uuid().required(), websiteId: yup.string().uuid().required(),
urls: yup.array().min(2).of(yup.string()).required(), steps: yup
.array()
.of(
yup.object().shape({
type: yup.string().required(),
value: yup.string().required(),
}),
)
.min(2)
.required(),
window: yup.number().positive().required(), window: yup.number().positive().required(),
dateRange: yup dateRange: yup
.object() .object()
@ -49,7 +58,7 @@ export default async (
if (req.method === 'POST') { if (req.method === 'POST') {
const { const {
websiteId, websiteId,
urls, steps,
window, window,
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = req.body; } = req.body;
@ -61,7 +70,7 @@ export default async (
const data = await getFunnel(websiteId, { const data = await getFunnel(websiteId, {
startDate: new Date(startDate), startDate: new Date(startDate),
endDate: new Date(endDate), endDate: new Date(endDate),
urls, steps,
windowMinutes: +window, windowMinutes: +window,
}); });

View File

@ -98,7 +98,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
} }
const { type, payload } = req.body; const { type, payload } = req.body;
const { url, referrer, name: eventName, data: eventData, title } = payload; const { url, referrer, name: eventName, data, title } = payload;
const pageTitle = safeDecodeURI(title); const pageTitle = safeDecodeURI(title);
await useSession(req, res); await useSession(req, res);
@ -142,7 +142,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
referrerDomain, referrerDomain,
pageTitle, pageTitle,
eventName, eventName,
eventData, eventData: data,
...session, ...session,
sessionId: session.id, sessionId: session.id,
visitId: session.visitId, visitId: session.visitId,
@ -150,14 +150,14 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
} }
if (type === COLLECTION_TYPE.identify) { if (type === COLLECTION_TYPE.identify) {
if (!eventData) { if (!data) {
return badRequest(res, 'Data required.'); return badRequest(res, 'Data required.');
} }
await saveSessionData({ await saveSessionData({
websiteId: session.websiteId, websiteId: session.websiteId,
sessionId: session.id, sessionId: session.id,
sessionData: eventData, sessionData: data,
}); });
} }

View File

@ -1,6 +1,6 @@
import { canViewWebsite } from 'lib/auth'; import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { parseDateRangeQuery } from 'lib/query'; import { getRequestDateRange } from 'lib/request';
import { NextApiRequestQueryBody, WebsiteMetric } from 'lib/types'; import { NextApiRequestQueryBody, WebsiteMetric } from 'lib/types';
import { TimezoneTest, UnitTypeTest } from 'lib/yup'; import { TimezoneTest, UnitTypeTest } from 'lib/yup';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
@ -37,7 +37,7 @@ export default async (
await useValidate(schema, req, res); await useValidate(schema, req, res);
const { websiteId, timezone, url } = req.query; const { websiteId, timezone, url } = req.query;
const { startDate, endDate, unit } = await parseDateRangeQuery(req); const { startDate, endDate, unit } = await getRequestDateRange(req);
if (req.method === 'GET') { if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {

View File

@ -5,7 +5,7 @@ import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants'; import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants';
import { getPageviewMetrics, getSessionMetrics } from 'queries'; import { getPageviewMetrics, getSessionMetrics } from 'queries';
import { getQueryFilters, parseDateRangeQuery } from 'lib/query'; import { getRequestFilters, getRequestDateRange } from 'lib/request';
import * as yup from 'yup'; import * as yup from 'yup';
export interface WebsiteMetricsRequestQuery { export interface WebsiteMetricsRequestQuery {
@ -69,16 +69,17 @@ export default async (
return unauthorized(res); return unauthorized(res);
} }
const { startDate, endDate } = await parseDateRangeQuery(req); const { startDate, endDate } = await getRequestDateRange(req);
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
const filters = { const filters = {
...getQueryFilters(req), ...getRequestFilters(req),
startDate, startDate,
endDate, endDate,
}; };
if (search) { if (search) {
filters[type] = { filters[type] = {
name: type,
column, column,
operator: OPERATORS.contains, operator: OPERATORS.contains,
value: search, value: search,

View File

@ -1,6 +1,6 @@
import { canViewWebsite } from 'lib/auth'; import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { getQueryFilters, parseDateRangeQuery } from 'lib/query'; import { getRequestFilters, getRequestDateRange } from 'lib/request';
import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types'; import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
@ -59,10 +59,10 @@ export default async (
return unauthorized(res); return unauthorized(res);
} }
const { startDate, endDate, unit } = await parseDateRangeQuery(req); const { startDate, endDate, unit } = await getRequestDateRange(req);
const filters = { const filters = {
...getQueryFilters(req), ...getRequestFilters(req),
startDate, startDate,
endDate, endDate,
timezone, timezone,

View File

@ -4,7 +4,7 @@ import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { canViewWebsite } from 'lib/auth'; import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types'; import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types';
import { getQueryFilters, parseDateRangeQuery } from 'lib/query'; import { getRequestFilters, getRequestDateRange } from 'lib/request';
import { getWebsiteStats } from 'queries'; import { getWebsiteStats } from 'queries';
export interface WebsiteStatsRequestQuery { export interface WebsiteStatsRequestQuery {
@ -59,12 +59,12 @@ export default async (
return unauthorized(res); return unauthorized(res);
} }
const { startDate, endDate } = await parseDateRangeQuery(req); const { startDate, endDate } = await getRequestDateRange(req);
const diff = differenceInMinutes(endDate, startDate); const diff = differenceInMinutes(endDate, startDate);
const prevStartDate = subMinutes(startDate, diff); const prevStartDate = subMinutes(startDate, diff);
const prevEndDate = subMinutes(endDate, diff); const prevEndDate = subMinutes(endDate, diff);
const filters = getQueryFilters(req); const filters = getRequestFilters(req);
const metrics = await getWebsiteStats(websiteId, { ...filters, startDate, endDate }); const metrics = await getWebsiteStats(websiteId, { ...filters, startDate, endDate });

View File

@ -11,7 +11,7 @@ import {
} from 'next-basics'; } from 'next-basics';
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants';
import { getValues } from 'queries'; import { getValues } from 'queries';
import { parseDateRangeQuery } from 'lib/query'; import { getRequestDateRange } from 'lib/request';
import * as yup from 'yup'; import * as yup from 'yup';
export interface ValuesRequestQuery { export interface ValuesRequestQuery {
@ -38,7 +38,7 @@ export default async (req: NextApiRequestQueryBody<ValuesRequestQuery>, res: Nex
await useValidate(schema, req, res); await useValidate(schema, req, res);
const { websiteId, type, search } = req.query; const { websiteId, type, search } = req.query;
const { startDate, endDate } = await parseDateRangeQuery(req); const { startDate, endDate } = await getRequestDateRange(req);
if (req.method === 'GET') { if (req.method === 'GET') {
if (!SESSION_COLUMNS.includes(type as string) && !EVENT_COLUMNS.includes(type as string)) { if (!SESSION_COLUMNS.includes(type as string) && !EVENT_COLUMNS.includes(type as string)) {

View File

@ -2,8 +2,8 @@ import clickhouse from 'lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
const formatResults = (urls: string[]) => (results: unknown) => { const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return urls.map((url: string, i: number) => { return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0; const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 0; const previous = Number(results[i - 1]?.count) || 0;
const dropped = previous > 0 ? previous - visitors : 0; const dropped = previous > 0 ? previous - visitors : 0;
@ -11,7 +11,7 @@ const formatResults = (urls: string[]) => (results: unknown) => {
const remaining = visitors / Number(results[0].count); const remaining = visitors / Number(results[0].count);
return { return {
url, ...step,
visitors, visitors,
previous, previous,
dropped, dropped,
@ -28,7 +28,7 @@ export async function getFunnel(
windowMinutes: number; windowMinutes: number;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
urls: string[]; steps: { type: string; value: string }[];
}, },
] ]
) { ) {
@ -44,32 +44,46 @@ async function relationalQuery(
windowMinutes: number; windowMinutes: number;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
urls: string[]; steps: { type: string; value: string }[];
}, },
): Promise< ): Promise<
{ {
url: string; value: string;
visitors: number; visitors: number;
dropoff: number; dropoff: number;
}[] }[]
> { > {
const { windowMinutes, startDate, endDate, urls } = criteria; const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery, getAddIntervalQuery } = prisma; const { rawQuery, getAddIntervalQuery } = prisma;
const { levelQuery, sumQuery } = getFunnelQuery(urls, windowMinutes); const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, windowMinutes);
function getFunnelQuery( function getFunnelQuery(
urls: string[], steps: { type: string; value: string }[],
windowMinutes: number, windowMinutes: number,
): { ): {
levelOneQuery: string;
levelQuery: string; levelQuery: string;
sumQuery: string; sumQuery: string;
params: string[];
} { } {
return urls.reduce( return steps.reduce(
(pv, cv, i) => { (pv, cv, i) => {
const levelNumber = i + 1; const levelNumber = i + 1;
const startSum = i > 0 ? 'union ' : ''; const startSum = i > 0 ? 'union ' : '';
const operator = cv.type === 'url' && cv.value.endsWith('*') ? 'like' : '=';
const column = cv.type === 'url' ? 'url_path' : 'event_name';
const paramValue = cv.value.endsWith('*') ? cv.value.replace('*', '%') : cv.value;
if (levelNumber >= 2) { if (levelNumber === 1) {
pv.levelOneQuery = `
WITH level1 AS (
select distinct session_id, created_at
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and ${column} ${operator} {{${i}}}
)`;
} else {
pv.levelQuery += ` pv.levelQuery += `
, level${levelNumber} AS ( , level${levelNumber} AS (
select distinct we.session_id, we.created_at select distinct we.session_id, we.created_at
@ -81,32 +95,28 @@ async function relationalQuery(
`l.created_at `, `l.created_at `,
`${windowMinutes} minute`, `${windowMinutes} minute`,
)} )}
and we.referrer_path = {{${i - 1}}} and we.${column} ${operator} {{${i}}}
and we.url_path = {{${i}}}
and we.created_at <= {{endDate}} and we.created_at <= {{endDate}}
)`; )`;
} }
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.params.push(paramValue);
return pv; return pv;
}, },
{ {
levelOneQuery: '',
levelQuery: '', levelQuery: '',
sumQuery: '', sumQuery: '',
params: [],
}, },
); );
} }
return rawQuery( return rawQuery(
` `
WITH level1 AS ( ${levelOneQuery}
select distinct session_id, created_at
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and url_path = {{0}}
)
${levelQuery} ${levelQuery}
${sumQuery} ${sumQuery}
ORDER BY level; ORDER BY level;
@ -115,9 +125,9 @@ async function relationalQuery(
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
...urls, ...params,
}, },
).then(formatResults(urls)); ).then(formatResults(steps));
} }
async function clickhouseQuery( async function clickhouseQuery(
@ -126,61 +136,76 @@ async function clickhouseQuery(
windowMinutes: number; windowMinutes: number;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
urls: string[]; steps: { type: string; value: string }[];
}, },
): Promise< ): Promise<
{ {
url: string; value: string;
visitors: number; visitors: number;
dropoff: number; dropoff: number;
}[] }[]
> { > {
const { windowMinutes, startDate, endDate, urls } = criteria; const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery } = clickhouse; const { rawQuery } = clickhouse;
const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getFunnelQuery(urls, windowMinutes); const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
steps,
windowMinutes,
);
function getFunnelQuery( function getFunnelQuery(
urls: string[], steps: { type: string; value: string }[],
windowMinutes: number, windowMinutes: number,
): { ): {
levelOneQuery: string;
levelQuery: string; levelQuery: string;
sumQuery: string; sumQuery: string;
urlFilterQuery: string; stepFilterQuery: string;
urlParams: { [key: string]: string }; params: { [key: string]: string };
} { } {
return urls.reduce( return steps.reduce(
(pv, cv, i) => { (pv, cv, i) => {
const levelNumber = i + 1; const levelNumber = i + 1;
const startSum = i > 0 ? 'union all ' : ''; const startSum = i > 0 ? 'union all ' : '';
const startFilter = i > 0 ? ', ' : ''; const startFilter = i > 0 ? 'or' : '';
const operator = cv.type === 'url' && cv.value.endsWith('*') ? 'like' : '=';
const column = cv.type === 'url' ? 'url_path' : 'event_name';
const paramValue = cv.value.endsWith('*') ? cv.value.replace('*', '%') : cv.value;
if (levelNumber >= 2) { if (levelNumber === 1) {
pv.levelOneQuery = `\n
level1 AS (
select *
from level0
where ${column} ${operator} {param${i}:String}
)`;
} else {
pv.levelQuery += `\n pv.levelQuery += `\n
, level${levelNumber} AS ( , level${levelNumber} AS (
select distinct y.session_id as session_id, select distinct y.session_id as session_id,
y.url_path as url_path, y.url_path as url_path,
y.referrer_path as referrer_path, y.referrer_path as referrer_path,
y.event_name,
y.created_at as created_at y.created_at as created_at
from level${i} x from level${i} x
join level0 y join level0 y
on x.session_id = y.session_id on x.session_id = y.session_id
where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute
and y.referrer_path = {url${i - 1}:String} and y.${column} ${operator} {param${i}:String}
and y.url_path = {url${i}:String}
)`; )`;
} }
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.urlFilterQuery += `${startFilter}{url${i}:String} `; pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `;
pv.urlParams[`url${i}`] = cv; pv.params[`param${i}`] = paramValue;
return pv; return pv;
}, },
{ {
levelOneQuery: '',
levelQuery: '', levelQuery: '',
sumQuery: '', sumQuery: '',
urlFilterQuery: '', stepFilterQuery: '',
urlParams: {}, params: {},
}, },
); );
} }
@ -188,17 +213,13 @@ async function clickhouseQuery(
return rawQuery( return rawQuery(
` `
WITH level0 AS ( WITH level0 AS (
select distinct session_id, url_path, referrer_path, created_at select distinct session_id, url_path, referrer_path, event_name, created_at
from umami.website_event from umami.website_event
where url_path in (${urlFilterQuery}) where (${stepFilterQuery})
and website_id = {websiteId:UUID} and website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
), ),
level1 AS ( ${levelOneQuery}
select *
from level0
where url_path = {url0:String}
)
${levelQuery} ${levelQuery}
select * select *
from ( from (
@ -209,7 +230,7 @@ async function clickhouseQuery(
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
...urlParams, ...params,
}, },
).then(formatResults(urls)); ).then(formatResults(steps));
} }

View File

@ -63,6 +63,7 @@
title: encode(title), title: encode(title),
url: encode(currentUrl), url: encode(currentUrl),
referrer: encode(currentRef), referrer: encode(currentRef),
tag: tag ? tag : undefined,
}); });
/* Event handlers */ /* Event handlers */
@ -217,7 +218,6 @@
...getPayload(), ...getPayload(),
name: obj, name: obj,
data: typeof data === 'object' ? data : undefined, data: typeof data === 'object' ? data : undefined,
tag,
}); });
} else if (typeof obj === 'object') { } else if (typeof obj === 'object') {
return send(obj); return send(obj);