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);