diff --git a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx
index f1ebd30d..5612c554 100644
--- a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx
+++ b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx
@@ -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();
diff --git a/src/app/(main)/reports/[reportId]/FilterParameters.tsx b/src/app/(main)/reports/[reportId]/FilterParameters.tsx
index d88f785d..edc0ff4e 100644
--- a/src/app/(main)/reports/[reportId]/FilterParameters.tsx
+++ b/src/app/(main)/reports/[reportId]/FilterParameters.tsx
@@ -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 (
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}
/>
diff --git a/src/app/(main)/reports/[reportId]/Report.tsx b/src/app/(main)/reports/[reportId]/Report.tsx
index 76f73595..d6de9d42 100644
--- a/src/app/(main)/reports/[reportId]/Report.tsx
+++ b/src/app/(main)/reports/[reportId]/Report.tsx
@@ -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;
}) {
diff --git a/src/app/(main)/reports/funnel/FunnelChart.module.css b/src/app/(main)/reports/funnel/FunnelChart.module.css
index 0279ea03..45d0ea61 100644
--- a/src/app/(main)/reports/funnel/FunnelChart.module.css
+++ b/src/app/(main)/reports/funnel/FunnelChart.module.css
@@ -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;
}
diff --git a/src/app/(main)/reports/funnel/FunnelChart.tsx b/src/app/(main)/reports/funnel/FunnelChart.tsx
index 6207a177..0da71d6f 100644
--- a/src/app/(main)/reports/funnel/FunnelChart.tsx
+++ b/src/app/(main)/reports/funnel/FunnelChart.tsx
@@ -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 (
- {data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => {
+ {data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => {
return (
-
+
{index + 1}
- {formatMessage(labels.viewedPage)}:
- {url}
+
+ {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
+
+ {value}
+
+
+
+ {formatLongNumber(visitors)}
+ {formatMessage(labels.visitors)}
+
+
{(remaining * 100).toFixed(2)}%
-
-
- {remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
-
-
+
-
-
-
{formatLongNumber(visitors)}
-
{formatMessage(labels.visitors)}
-
({(remaining * 100).toFixed(2)}%)
+ {dropoff > 0 && (
+
+ {formatLongNumber(dropped)} {formatMessage(labels.visitorsDroppedOff)} (
+ {(dropoff * 100).toFixed(2)}%)
- {dropoff > 0 && (
-
- {formatLongNumber(dropped)} {formatMessage(labels.visitorsDroppedOff)} (
- {(dropoff * 100).toFixed(2)}%)
-
- )}
-
+ )}
);
diff --git a/src/app/(main)/reports/funnel/FunnelParameters.module.css b/src/app/(main)/reports/funnel/FunnelParameters.module.css
new file mode 100644
index 00000000..81ef9216
--- /dev/null
+++ b/src/app/(main)/reports/funnel/FunnelParameters.module.css
@@ -0,0 +1,9 @@
+.item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.type {
+ color: var(--base700);
+}
diff --git a/src/app/(main)/reports/funnel/FunnelParameters.tsx b/src/app/(main)/reports/funnel/FunnelParameters.tsx
index 2ea64cf5..248318f1 100644
--- a/src/app/(main)/reports/funnel/FunnelParameters.tsx
+++ b/src/app/(main)/reports/funnel/FunnelParameters.tsx
@@ -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 (
-
-
-
-
+
+
-
+
@@ -69,12 +75,17 @@ export function FunnelParameters() {
-
}>
+
}>
- {urls.map(url => {
+ {steps.map((step: { type: string; value: string }, index: number) => {
return (
- handleRemoveUrl(url)}>
- {url}
+ handleRemoveStep(index)}>
+
+
+ {step.type === 'url' ? : }
+
+
{step.value}
+
);
})}
diff --git a/src/app/(main)/reports/funnel/FunnelReport.tsx b/src/app/(main)/reports/funnel/FunnelReport.tsx
index 7b9a6677..850bbd90 100644
--- a/src/app/(main)/reports/funnel/FunnelReport.tsx
+++ b/src/app/(main)/reports/funnel/FunnelReport.tsx
@@ -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 }) {
diff --git a/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css b/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css
new file mode 100644
index 00000000..a254ff08
--- /dev/null
+++ b/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css
@@ -0,0 +1,7 @@
+.dropdown {
+ width: 140px;
+}
+
+.input {
+ width: 200px;
+}
diff --git a/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx b/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx
new file mode 100644
index 00000000..978747c9
--- /dev/null
+++ b/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx
@@ -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 (
+
+
+ setType(value)}
+ >
+ {({ value, label }) => {
+ return - {label}
;
+ }}
+
+
+
+
+
+ );
+}
+
+export default FunnelStepAddForm;
diff --git a/src/app/(main)/reports/funnel/UrlAddForm.module.css b/src/app/(main)/reports/funnel/UrlAddForm.module.css
deleted file mode 100644
index 6a3e03b5..00000000
--- a/src/app/(main)/reports/funnel/UrlAddForm.module.css
+++ /dev/null
@@ -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%;
-}
diff --git a/src/app/(main)/reports/funnel/UrlAddForm.tsx b/src/app/(main)/reports/funnel/UrlAddForm.tsx
deleted file mode 100644
index 88c27ae9..00000000
--- a/src/app/(main)/reports/funnel/UrlAddForm.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-export default UrlAddForm;
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
index 9fdc8fd0..ea037852 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
@@ -44,7 +44,6 @@ export default function WebsiteExpandedView({
const {
router,
renderUrl,
- pathname,
query: { view },
} = useNavigation();
@@ -122,7 +121,12 @@ export default function WebsiteExpandedView({
return (
-
+
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
index 2ec40d07..993618c2 100644
--- a/src/components/charts/Chart.tsx
+++ b/src/components/charts/Chart.tsx
@@ -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(() => {
diff --git a/src/components/hooks/queries/useReport.ts b/src/components/hooks/queries/useReport.ts
index 4dade4db..3aacabb4 100644
--- a/src/components/hooks/queries/useReport.ts
+++ b/src/components/hooks/queries/useReport.ts
@@ -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 };
}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 50774dfd..4057bbfd 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -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({
diff --git a/src/components/metrics/FilterTags.tsx b/src/components/metrics/FilterTags.tsx
index 24311d6e..a302575f 100644
--- a/src/components/metrics/FilterTags.tsx
+++ b/src/components/metrics/FilterTags.tsx
@@ -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 (
diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx
index ea9720fd..347bf155 100644
--- a/src/components/metrics/PageviewsChart.tsx
+++ b/src/components/metrics/PageviewsChart.tsx
@@ -38,7 +38,7 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
},
],
};
- }, [data]);
+ }, [data, locale]);
return (
{
- 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,
diff --git a/src/lib/params.ts b/src/lib/params.ts
index 10944c96..801c60b2 100644
--- a/src/lib/params.ts
+++ b/src/lib/params.ts
@@ -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,
+ });
+ }, []);
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index c3dd071b..c35e0cde 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -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,
diff --git a/src/lib/query.ts b/src/lib/request.ts
similarity index 63%
rename from src/lib/query.ts
rename to src/lib/request.ts
index 8b6bcee6..5e2be2fe 100644
--- a/src/lib/query.ts
+++ b/src/lib/request.ts
@@ -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;
}, {});
}
diff --git a/src/pages/api/reports/funnel.ts b/src/pages/api/reports/funnel.ts
index 9071b962..35759a30 100644
--- a/src/pages/api/reports/funnel.ts
+++ b/src/pages/api/reports/funnel.ts
@@ -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,
});
diff --git a/src/pages/api/send.ts b/src/pages/api/send.ts
index 72fd7a64..0b43f920 100644
--- a/src/pages/api/send.ts
+++ b/src/pages/api/send.ts
@@ -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,
});
}
diff --git a/src/pages/api/websites/[websiteId]/events.ts b/src/pages/api/websites/[websiteId]/events.ts
index b4d465a4..d07fd28f 100644
--- a/src/pages/api/websites/[websiteId]/events.ts
+++ b/src/pages/api/websites/[websiteId]/events.ts
@@ -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))) {
diff --git a/src/pages/api/websites/[websiteId]/metrics.ts b/src/pages/api/websites/[websiteId]/metrics.ts
index 2387d9c2..02e3a009 100644
--- a/src/pages/api/websites/[websiteId]/metrics.ts
+++ b/src/pages/api/websites/[websiteId]/metrics.ts
@@ -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,
diff --git a/src/pages/api/websites/[websiteId]/pageviews.ts b/src/pages/api/websites/[websiteId]/pageviews.ts
index 9ac4e870..19671064 100644
--- a/src/pages/api/websites/[websiteId]/pageviews.ts
+++ b/src/pages/api/websites/[websiteId]/pageviews.ts
@@ -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,
diff --git a/src/pages/api/websites/[websiteId]/stats.ts b/src/pages/api/websites/[websiteId]/stats.ts
index dfc3df93..81a6d835 100644
--- a/src/pages/api/websites/[websiteId]/stats.ts
+++ b/src/pages/api/websites/[websiteId]/stats.ts
@@ -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 });
diff --git a/src/pages/api/websites/[websiteId]/values.ts b/src/pages/api/websites/[websiteId]/values.ts
index 36ca7948..364261d9 100644
--- a/src/pages/api/websites/[websiteId]/values.ts
+++ b/src/pages/api/websites/[websiteId]/values.ts
@@ -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, 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)) {
diff --git a/src/queries/analytics/reports/getFunnel.ts b/src/queries/analytics/reports/getFunnel.ts
index 0a5dfd96..f9ceb85c 100644
--- a/src/queries/analytics/reports/getFunnel.ts
+++ b/src/queries/analytics/reports/getFunnel.ts
@@ -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));
}
diff --git a/src/tracker/index.js b/src/tracker/index.js
index 36110537..43a51501 100644
--- a/src/tracker/index.js
+++ b/src/tracker/index.js
@@ -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);