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

View File

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

View File

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

View File

@ -37,12 +37,12 @@
.card {
display: grid;
gap: 20px;
margin-top: 8px;
}
.header {
display: flex;
align-items: center;
font-weight: 700;
flex-direction: column;
gap: 20px;
}
@ -51,19 +51,16 @@
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 50px;
height: 30px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.label {
color: var(--base700);
}
.value {
color: var(--base50);
margin-inline-end: 20px;
color: var(--base600);
font-weight: 700;
text-transform: uppercase;
}
.track {
@ -72,13 +69,33 @@
}
.info {
display: flex;
justify-content: space-between;
text-transform: lowercase;
}
.item {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--base300);
font-size: 24px;
color: var(--base900);
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 { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps {
className?: string;
@ -18,35 +18,33 @@ export function FunnelChart({ className }: FunnelChartProps) {
return (
<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 (
<div key={url} className={styles.step}>
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span>
<span className={styles.item}>{url}</span>
<span className={styles.label}>
{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 className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
</div>
<div className={styles.info}>
<div>
<b>{formatLongNumber(visitors)}</b>
<span> {formatMessage(labels.visitors)}</span>
<span> ({(remaining * 100).toFixed(2)}%)</span>
{dropoff > 0 && (
<div className={styles.info}>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
{dropoff > 0 && (
<div>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</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,
SubmitButton,
TextField,
Button,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddUrl = (url: string) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleRemoveUrl = (url: string) => {
const urls = [...parameters.urls];
updateReport({ parameters: { urls: urls.filter(n => n !== url) } });
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddUrlButton = () => {
const AddStepButton = () => {
return (
<PopupTrigger>
<Icon>
<Icons.Plus />
</Icon>
<Popup position="right" alignment="start">
<Button>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup alignment="start">
<PopupForm>
<UrlAddForm onAdd={handleAddUrl} />
<FunnelStepAddForm onAdd={handleAddStep} />
</PopupForm>
</Popup>
</PopupTrigger>
@ -69,12 +75,17 @@ export function FunnelParameters() {
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}>
<FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList>
{urls.map(url => {
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<ParameterList.Item key={url} onRemove={() => handleRemoveUrl(url)}>
{url}
<ParameterList.Item key={index} onRemove={() => handleRemoveStep(index)}>
<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>
);
})}

View File

@ -9,7 +9,7 @@ import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
type: REPORT_TYPES.funnel,
parameters: { window: 60, urls: [] },
parameters: { window: 60, steps: [] },
};
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 {
router,
renderUrl,
pathname,
query: { view },
} = useNavigation();
@ -122,7 +121,12 @@ export default function WebsiteExpandedView({
return (
<div className={styles.layout}>
<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}>
<Icons.ArrowRight />
</Icon>

View File

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

View File

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

View File

@ -232,6 +232,8 @@ export const labels = defineMessages({
id: 'label.utm-description',
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({

View File

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

View File

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

View File

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

View File

@ -1,15 +1,46 @@
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) {
const [, prefix, value] = param.match(/^(!~|!|~)?(.*)$/);
export function parseParameterValue(param: any) {
if (typeof param === 'string') {
const [, prefix, value] = param.match(/^(!~|!|~)?(.*)$/);
const operator =
Object.keys(OPERATOR_PREFIXES).find(key => OPERATOR_PREFIXES[key] === prefix) ||
OPERATORS.equals;
const operator =
Object.keys(OPERATOR_PREFIXES).find(key => OPERATOR_PREFIXES[key] === prefix) ||
OPERATORS.equals;
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);
}
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 moment from 'moment-timezone';
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 { maxDate } from './date';
import { QueryFilters, QueryOptions, SearchFilter } from './types';
import { filtersToArray } from './params';
const MYSQL_DATE_FORMATS = {
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 {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
const operator = filter?.operator ?? OPERATORS.equals;
const column = filter?.column ?? FILTER_COLUMNS[key] ?? options?.columns?.[key];
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) {
arr.push(`and ${mapFilter(column, operator, name)}`);
if (filter !== undefined && column !== undefined) {
arr.push(`and ${mapFilter(column, operator, key)}`);
if (key === 'referrer') {
if (name === 'referrer') {
arr.push(
'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');
}
function normalizeFilters(filters = {}) {
return Object.keys(filters).reduce((obj, key) => {
const filter = filters[key];
const value = filter?.value ?? filter;
obj[key] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(filter?.operator)
function getFilterParams(filters: QueryFilters = {}) {
return filtersToArray(filters).reduce((obj, { name, operator, value }) => {
obj[name] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator)
? `%${value}%`
: value;
@ -152,15 +146,16 @@ async function parseFilters(
options: QueryOptions = {},
) {
const website = await loadWebsite(websiteId);
const joinSession = Object.keys(filters).find(key => SESSION_COLUMNS.includes(key));
return {
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`
: '',
filterQuery: getFilterQuery(filters, options),
params: {
...normalizeFilters(filters),
...getFilterParams(filters),
websiteId,
startDate: maxDate(filters.startDate, website?.resetAt),
websiteDomain: website.domain,

View File

@ -1,9 +1,9 @@
import { NextApiRequest } from 'next';
import { getAllowedUnits, getMinimumUnit } from './date';
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;
// 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) => {
const value = req.query[key];
if (value) {
if (value !== undefined) {
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;
}, {});
}

View File

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

View File

@ -98,7 +98,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
}
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);
await useSession(req, res);
@ -142,7 +142,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
referrerDomain,
pageTitle,
eventName,
eventData,
eventData: data,
...session,
sessionId: session.id,
visitId: session.visitId,
@ -150,14 +150,14 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
}
if (type === COLLECTION_TYPE.identify) {
if (!eventData) {
if (!data) {
return badRequest(res, 'Data required.');
}
await saveSessionData({
websiteId: session.websiteId,
sessionId: session.id,
sessionData: eventData,
sessionData: data,
});
}

View File

@ -1,6 +1,6 @@
import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware';
import { parseDateRangeQuery } from 'lib/query';
import { getRequestDateRange } from 'lib/request';
import { NextApiRequestQueryBody, WebsiteMetric } from 'lib/types';
import { TimezoneTest, UnitTypeTest } from 'lib/yup';
import { NextApiResponse } from 'next';
@ -37,7 +37,7 @@ export default async (
await useValidate(schema, req, res);
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 (!(await canViewWebsite(req.auth, websiteId))) {

View File

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

View File

@ -1,6 +1,6 @@
import { canViewWebsite } from 'lib/auth';
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 { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
@ -59,10 +59,10 @@ export default async (
return unauthorized(res);
}
const { startDate, endDate, unit } = await parseDateRangeQuery(req);
const { startDate, endDate, unit } = await getRequestDateRange(req);
const filters = {
...getQueryFilters(req),
...getRequestFilters(req),
startDate,
endDate,
timezone,

View File

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

View File

@ -11,7 +11,7 @@ import {
} from 'next-basics';
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants';
import { getValues } from 'queries';
import { parseDateRangeQuery } from 'lib/query';
import { getRequestDateRange } from 'lib/request';
import * as yup from 'yup';
export interface ValuesRequestQuery {
@ -38,7 +38,7 @@ export default async (req: NextApiRequestQueryBody<ValuesRequestQuery>, res: Nex
await useValidate(schema, req, res);
const { websiteId, type, search } = req.query;
const { startDate, endDate } = await parseDateRangeQuery(req);
const { startDate, endDate } = await getRequestDateRange(req);
if (req.method === 'GET') {
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 prisma from 'lib/prisma';
const formatResults = (urls: string[]) => (results: unknown) => {
return urls.map((url: string, i: number) => {
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 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);
return {
url,
...step,
visitors,
previous,
dropped,
@ -28,7 +28,7 @@ export async function getFunnel(
windowMinutes: number;
startDate: Date;
endDate: Date;
urls: string[];
steps: { type: string; value: string }[];
},
]
) {
@ -44,32 +44,46 @@ async function relationalQuery(
windowMinutes: number;
startDate: Date;
endDate: Date;
urls: string[];
steps: { type: string; value: string }[];
},
): Promise<
{
url: string;
value: string;
visitors: number;
dropoff: number;
}[]
> {
const { windowMinutes, startDate, endDate, urls } = criteria;
const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery, getAddIntervalQuery } = prisma;
const { levelQuery, sumQuery } = getFunnelQuery(urls, windowMinutes);
const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, windowMinutes);
function getFunnelQuery(
urls: string[],
steps: { type: string; value: string }[],
windowMinutes: number,
): {
levelOneQuery: string;
levelQuery: string;
sumQuery: string;
params: string[];
} {
return urls.reduce(
return steps.reduce(
(pv, cv, i) => {
const levelNumber = i + 1;
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 += `
, level${levelNumber} AS (
select distinct we.session_id, we.created_at
@ -81,32 +95,28 @@ async function relationalQuery(
`l.created_at `,
`${windowMinutes} minute`,
)}
and we.referrer_path = {{${i - 1}}}
and we.url_path = {{${i}}}
and we.${column} ${operator} {{${i}}}
and we.created_at <= {{endDate}}
)`;
}
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.params.push(paramValue);
return pv;
},
{
levelOneQuery: '',
levelQuery: '',
sumQuery: '',
params: [],
},
);
}
return rawQuery(
`
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 url_path = {{0}}
)
${levelOneQuery}
${levelQuery}
${sumQuery}
ORDER BY level;
@ -115,9 +125,9 @@ async function relationalQuery(
websiteId,
startDate,
endDate,
...urls,
...params,
},
).then(formatResults(urls));
).then(formatResults(steps));
}
async function clickhouseQuery(
@ -126,61 +136,76 @@ async function clickhouseQuery(
windowMinutes: number;
startDate: Date;
endDate: Date;
urls: string[];
steps: { type: string; value: string }[];
},
): Promise<
{
url: string;
value: string;
visitors: number;
dropoff: number;
}[]
> {
const { windowMinutes, startDate, endDate, urls } = criteria;
const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery } = clickhouse;
const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getFunnelQuery(urls, windowMinutes);
const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
steps,
windowMinutes,
);
function getFunnelQuery(
urls: string[],
steps: { type: string; value: string }[],
windowMinutes: number,
): {
levelOneQuery: string;
levelQuery: string;
sumQuery: string;
urlFilterQuery: string;
urlParams: { [key: string]: string };
stepFilterQuery: string;
params: { [key: string]: string };
} {
return urls.reduce(
return steps.reduce(
(pv, cv, i) => {
const levelNumber = i + 1;
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
, level${levelNumber} AS (
select distinct y.session_id as session_id,
y.url_path as url_path,
y.referrer_path as referrer_path,
y.event_name,
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 ${windowMinutes} minute
and y.referrer_path = {url${i - 1}:String}
and y.url_path = {url${i}:String}
and y.${column} ${operator} {param${i}:String}
)`;
}
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.urlFilterQuery += `${startFilter}{url${i}:String} `;
pv.urlParams[`url${i}`] = cv;
pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `;
pv.params[`param${i}`] = paramValue;
return pv;
},
{
levelOneQuery: '',
levelQuery: '',
sumQuery: '',
urlFilterQuery: '',
urlParams: {},
stepFilterQuery: '',
params: {},
},
);
}
@ -188,17 +213,13 @@ async function clickhouseQuery(
return rawQuery(
`
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
where url_path in (${urlFilterQuery})
where (${stepFilterQuery})
and website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
),
level1 AS (
select *
from level0
where url_path = {url0:String}
)
${levelOneQuery}
${levelQuery}
select *
from (
@ -209,7 +230,7 @@ async function clickhouseQuery(
websiteId,
startDate,
endDate,
...urlParams,
...params,
},
).then(formatResults(urls));
).then(formatResults(steps));
}

View File

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