add event-data property filtering
This commit is contained in:
@@ -125,6 +125,7 @@
|
||||
"filter-combined": "Combined",
|
||||
"filter-raw": "Raw",
|
||||
"filters": "Filters",
|
||||
"property-filter": "Property Filter",
|
||||
"first-click": "First click",
|
||||
"first-seen": "First seen",
|
||||
"funnel": "Funnel",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { Button, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { X } from '@/components/icons';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
|
||||
export function EventDataFilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
}: {
|
||||
filters: EventPropertyFilter[];
|
||||
onChange: (filters: EventPropertyFilter[]) => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
if (!filters.length) return null;
|
||||
|
||||
const operatorLabel = (op: string) => {
|
||||
switch (op) {
|
||||
case 'eq': return t(labels.is);
|
||||
case 'neq': return t(labels.isNot);
|
||||
case 'c': return t(labels.contains);
|
||||
case 'dnc': return t(labels.doesNotContain);
|
||||
case 'regex': return t(labels.regexMatch);
|
||||
case 'notRegex': return t(labels.regexNotMatch);
|
||||
case 'gt': return t(labels.greaterThan);
|
||||
case 'lt': return t(labels.lessThan);
|
||||
case 'gte': return t(labels.greaterThanEquals);
|
||||
case 'lte': return t(labels.lessThanEquals);
|
||||
default: return op;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="surface-sunken" wrap="wrap">
|
||||
<Row alignItems="center" gap="2" wrap="wrap" width={{ base: '100%', md: 'auto' }}>
|
||||
{filters.map((filter, index) => (
|
||||
<FilterPill
|
||||
key={`${filter.propertyName}-${index}`}
|
||||
label={filter.propertyName}
|
||||
operator={operatorLabel(filter.operator)}
|
||||
value={filter.value}
|
||||
onRemove={() => handleRemove(index)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="zero" onPress={() => onChange([])}>
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<Text>{t(labels.clearAll)}</Text>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPill({
|
||||
label,
|
||||
operator,
|
||||
value,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Row border padding="2" color backgroundColor borderRadius alignItems="center" gap="4" theme="dark">
|
||||
<Row alignItems="center" gap="2" style={{ maxWidth: 'min(500px, calc(100vw - 10rem))', minWidth: 0, overflow: 'hidden' }}>
|
||||
<Text color="primary" weight="bold">
|
||||
{label}
|
||||
</Text>
|
||||
<Text color="muted">{operator}</Text>
|
||||
<Text
|
||||
color="primary"
|
||||
weight="bold"
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</Row>
|
||||
<Icon onClick={onRemove} size="xs" style={{ cursor: 'pointer' }}>
|
||||
<X />
|
||||
</Icon>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { ListFilter } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
import { EventDataFilterEditForm } from './EventDataFilterEditForm';
|
||||
|
||||
export function EventDataFilterButton({
|
||||
websiteId,
|
||||
eventName,
|
||||
eventFilters,
|
||||
onApply,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
eventFilters: EventPropertyFilter[];
|
||||
onApply: (filters: EventPropertyFilter[]) => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogButton icon={<ListFilter />} label={t(labels.propertyFilter)} variant="outline">
|
||||
{({ close }) => (
|
||||
<EventDataFilterEditForm
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
value={eventFilters}
|
||||
onApply={onApply}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
import { Button, Column, Grid, Icon, List, ListItem, Menu, MenuItem, MenuTrigger, Popover, Row } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useEventDataFieldsQuery, useMessages, useMobile } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
import { EventDataFilterRecord } from './EventDataFilterRecord';
|
||||
|
||||
export function EventDataFilterEditForm({
|
||||
websiteId,
|
||||
eventName,
|
||||
value,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
value: EventPropertyFilter[];
|
||||
onApply: (filters: EventPropertyFilter[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const { isMobile } = useMobile();
|
||||
const [filters, setFilters] = useState<EventPropertyFilter[]>(value);
|
||||
|
||||
const { data: fields = [] } = useEventDataFieldsQuery(websiteId, eventName);
|
||||
|
||||
const handleAdd = (propertyName: string) => {
|
||||
const field = (fields as any[]).find(f => f.propertyName === propertyName);
|
||||
const dataType: number = field?.dataType ?? 1;
|
||||
setFilters(prev => [...prev, { propertyName, dataType, operator: 'eq', value: '' }]);
|
||||
};
|
||||
|
||||
const handleChange = (index: number, filter: EventPropertyFilter) => {
|
||||
setFilters(prev => prev.map((f, i) => (i === index ? filter : f)));
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
setFilters(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<Column width={isMobile ? 'auto' : '700px'} gap="6">
|
||||
<Column minHeight="400px">
|
||||
<Grid columns={{ base: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
|
||||
<Row display={{ base: 'flex', md: 'none' }}>
|
||||
<MenuTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom start" shouldFlip>
|
||||
<Menu
|
||||
onAction={key => handleAdd(key.toString())}
|
||||
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
|
||||
>
|
||||
{(fields as any[]).map(field => (
|
||||
<MenuItem key={field.propertyName} id={field.propertyName}>
|
||||
{field.propertyName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
</Row>
|
||||
<Column
|
||||
display={{ base: 'none', md: 'flex' }}
|
||||
border="right"
|
||||
paddingRight="3"
|
||||
marginRight="6"
|
||||
>
|
||||
<List onAction={key => handleAdd(key.toString())}>
|
||||
{(fields as any[]).map(field => (
|
||||
<ListItem key={field.propertyName} id={field.propertyName}>
|
||||
{field.propertyName}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Column>
|
||||
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
|
||||
{filters.map((filter, index) => (
|
||||
<EventDataFilterRecord
|
||||
key={`${filter.propertyName}-${index}`}
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
filter={filter}
|
||||
onChange={f => handleChange(index, f)}
|
||||
onRemove={() => handleRemove(index)}
|
||||
/>
|
||||
))}
|
||||
{!filters.length && <Empty message={t(messages.nothingSelected)} />}
|
||||
</Column>
|
||||
</Grid>
|
||||
</Column>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Button onPress={() => setFilters([])}>{t(labels.reset)}</Button>
|
||||
<Row gap>
|
||||
<Button onPress={onClose}>{t(labels.cancel)}</Button>
|
||||
<Button variant="primary" onPress={() => { onApply(filters); onClose(); }}>
|
||||
{t(labels.apply)}
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
import { Button, Column, Grid, Icon, Label, ListItem, Loading, Select, TextField } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { MultiSelect } from '@/components/common/MultiSelect';
|
||||
import { useEventDataValuesQuery, useMessages } from '@/components/hooks';
|
||||
import { X } from '@/components/icons';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
|
||||
const STRING_OPERATORS = ['eq', 'neq', 'c', 'dnc', 'regex', 'notRegex'] as const;
|
||||
const NUMERIC_OPERATORS = ['eq', 'neq', 'gt', 'lt', 'gte', 'lte'] as const;
|
||||
|
||||
export function EventDataFilterRecord({
|
||||
websiteId,
|
||||
eventName,
|
||||
filter,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
filter: EventPropertyFilter;
|
||||
onChange: (filter: EventPropertyFilter) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const isNumeric = filter.dataType === 2;
|
||||
const operators = isNumeric ? NUMERIC_OPERATORS : STRING_OPERATORS;
|
||||
const isFreeText = filter.operator === 'c' || filter.operator === 'dnc' || filter.operator === 'regex' || filter.operator === 'notRegex';
|
||||
|
||||
const { data, isLoading } = useEventDataValuesQuery(
|
||||
websiteId,
|
||||
eventName,
|
||||
filter.propertyName,
|
||||
{ enabled: !isNumeric && !isFreeText },
|
||||
);
|
||||
|
||||
const values = (data as Array<{ value: string }> | undefined)?.map(d => d.value) ?? [];
|
||||
const filteredValues = search
|
||||
? values.filter(v => v.toLowerCase().includes(search.toLowerCase()))
|
||||
: values;
|
||||
const selected = filter.value ? filter.value.split(',').filter(Boolean) : [];
|
||||
|
||||
const operatorLabel = (op: string) => {
|
||||
switch (op) {
|
||||
case 'eq': return t(labels.is);
|
||||
case 'neq': return t(labels.isNot);
|
||||
case 'c': return t(labels.contains);
|
||||
case 'dnc': return t(labels.doesNotContain);
|
||||
case 'regex': return t(labels.regexMatch);
|
||||
case 'notRegex': return t(labels.regexNotMatch);
|
||||
case 'gt': return t(labels.greaterThan);
|
||||
case 'lt': return t(labels.lessThan);
|
||||
case 'gte': return t(labels.greaterThanEquals);
|
||||
case 'lte': return t(labels.lessThanEquals);
|
||||
default: return op;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOperatorChange = (op: string) => {
|
||||
// clear value when switching between multi-select and free-text modes
|
||||
const wasMulti = filter.operator === 'eq' || filter.operator === 'neq';
|
||||
const isMulti = op === 'eq' || op === 'neq';
|
||||
onChange({ ...filter, operator: op, value: wasMulti === isMulti ? filter.value : '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Label>{filter.propertyName}</Label>
|
||||
<Grid columns="1fr auto" gap>
|
||||
<Grid columns={{ base: '1fr', md: '200px 1fr' }} gap>
|
||||
<Select value={filter.operator} onChange={handleOperatorChange}>
|
||||
{operators.map((op: string) => (
|
||||
<ListItem key={op} id={op}>
|
||||
{operatorLabel(op)}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
{isNumeric || isFreeText ? (
|
||||
<TextField
|
||||
value={filter.value}
|
||||
onChange={v => onChange({ ...filter, value: v })}
|
||||
inputMode={isNumeric ? 'numeric' : 'text'}
|
||||
/>
|
||||
) : (
|
||||
<MultiSelect
|
||||
value={selected}
|
||||
onChange={vals => onChange({ ...filter, value: vals.join(',') })}
|
||||
searchValue={search}
|
||||
onSearch={setSearch}
|
||||
allowSearch
|
||||
renderValue={vals => (vals.length > 0 ? vals.join(', ') : undefined)}
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{filteredValues.map(v => (
|
||||
<ListItem key={v} id={v}>
|
||||
{v}
|
||||
</ListItem>
|
||||
))}
|
||||
</MultiSelect>
|
||||
)}
|
||||
</Grid>
|
||||
<Column justifyContent="flex-start">
|
||||
<Button onPress={onRemove}>
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -14,15 +14,18 @@ import {
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { getThemeColors } from '@/lib/colors';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
|
||||
export function EventDataNumericChart({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
eventFilters = [],
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
propertyName: string;
|
||||
eventFilters?: EventPropertyFilter[];
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { theme } = useTheme();
|
||||
@@ -36,12 +39,14 @@ export function EventDataNumericChart({
|
||||
eventName,
|
||||
propertyName,
|
||||
'sum',
|
||||
eventFilters,
|
||||
);
|
||||
const avgQuery = useEventDataNumericSeriesQuery(
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
'avg',
|
||||
eventFilters,
|
||||
);
|
||||
|
||||
const chartData: any = useMemo(() => {
|
||||
|
||||
@@ -8,20 +8,23 @@ import Link from '@/components/common/Link';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Pager } from '@/components/common/Pager';
|
||||
import { useEventDataPivotQuery, useEventDataPropertiesQuery, useMessages, useMobile, useNavigation } from '@/components/hooks';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
|
||||
export function EventDataPivotTable({
|
||||
websiteId,
|
||||
eventName,
|
||||
eventFilters = [],
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
eventFilters?: EventPropertyFilter[];
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { router, updateParams } = useNavigation();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
const propertiesQuery = useEventDataPropertiesQuery(websiteId);
|
||||
const pivotQuery = useEventDataPivotQuery(websiteId, eventName);
|
||||
const pivotQuery = useEventDataPivotQuery(websiteId, eventName, eventFilters);
|
||||
|
||||
const propertyKeys = useMemo(() => {
|
||||
if (!propertiesQuery.data || !eventName) return [];
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useDateRange,
|
||||
useEventDataPropertySeriesQuery,
|
||||
useEventDataValuesQuery,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useTimezone,
|
||||
@@ -17,26 +16,44 @@ import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
|
||||
export function EventDataPropertyChart({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
eventFilters = [],
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
propertyName: string;
|
||||
eventFilters?: EventPropertyFilter[];
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { timezone } = useTimezone();
|
||||
const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone });
|
||||
const { locale, dateLocale } = useLocale();
|
||||
const { data, isLoading, error } = useEventDataPropertySeriesQuery(websiteId, eventName, propertyName);
|
||||
const valuesQuery = useEventDataValuesQuery(websiteId, eventName, propertyName);
|
||||
const valueLabels = useMemo(
|
||||
() => valuesQuery.data?.map(({ value }) => value) ?? [],
|
||||
[valuesQuery.data],
|
||||
const { data, isLoading, isFetching, error } = useEventDataPropertySeriesQuery(
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
eventFilters,
|
||||
);
|
||||
|
||||
// Aggregate totals per value from the already-filtered time series
|
||||
const aggregated = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const totals = (data as any[]).reduce((obj: Record<string, number>, { x, y }) => {
|
||||
obj[x] = (obj[x] ?? 0) + y;
|
||||
return obj;
|
||||
}, {});
|
||||
return Object.entries(totals)
|
||||
.map(([value, total]) => ({ value, total: total as number }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
const valueLabels = useMemo(() => aggregated.map(({ value }) => value), [aggregated]);
|
||||
|
||||
const colorMap = useMemo(() => {
|
||||
return valueLabels.reduce(
|
||||
(obj, label, index) => {
|
||||
@@ -50,13 +67,9 @@ export function EventDataPropertyChart({
|
||||
const chartData: any = useMemo(() => {
|
||||
if (!data) return;
|
||||
|
||||
const map = (data as any[]).reduce((obj, { x, t, y }) => {
|
||||
if (!obj[x]) {
|
||||
obj[x] = [];
|
||||
}
|
||||
|
||||
const map = (data as any[]).reduce((obj: Record<string, { x: string; y: number }[]>, { x, t, y }) => {
|
||||
if (!obj[x]) obj[x] = [];
|
||||
obj[x].push({ x: t, y });
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
@@ -70,57 +83,57 @@ export function EventDataPropertyChart({
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const keys = [
|
||||
...valueLabels.filter(label => map[label]),
|
||||
...Object.keys(map).filter(key => !valueLabels.includes(key)),
|
||||
];
|
||||
|
||||
return {
|
||||
datasets: keys.map((key, index) => {
|
||||
const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const keys = [
|
||||
...valueLabels.filter(label => map[label]),
|
||||
...Object.keys(map).filter(key => !valueLabels.includes(key)),
|
||||
];
|
||||
|
||||
return {
|
||||
datasets: keys.map((key, index) => {
|
||||
const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [data, startDate, endDate, unit, dateLocale, valueLabels, colorMap]);
|
||||
|
||||
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
|
||||
const propertySum = useMemo(() => {
|
||||
return valuesQuery.data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
|
||||
}, [valuesQuery.data]);
|
||||
|
||||
const propertySum = useMemo(
|
||||
() => aggregated.reduce((sum, { total }) => sum + total, 0),
|
||||
[aggregated],
|
||||
);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!valuesQuery.data || propertySum === 0) return [];
|
||||
|
||||
return valuesQuery.data.map(({ value, total }) => ({
|
||||
if (!aggregated.length || propertySum === 0) return [];
|
||||
return aggregated.map(({ value, total }) => ({
|
||||
label: value,
|
||||
count: total,
|
||||
percent: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [valuesQuery.data, propertySum]);
|
||||
}, [aggregated, propertySum]);
|
||||
|
||||
const pieChartData: any = useMemo(() => {
|
||||
if (!valuesQuery.data?.length) return null;
|
||||
|
||||
if (!aggregated.length) return null;
|
||||
return {
|
||||
labels: valueLabels,
|
||||
datasets: [
|
||||
{
|
||||
data: valuesQuery.data.map(({ total }) => total),
|
||||
data: aggregated.map(({ total }) => total),
|
||||
backgroundColor: valueLabels.map(label => colorMap[label]),
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [valuesQuery.data, valueLabels, colorMap]);
|
||||
}, [aggregated, valueLabels, colorMap]);
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
@@ -139,9 +152,9 @@ export function EventDataPropertyChart({
|
||||
</LoadingPanel>
|
||||
<LoadingPanel
|
||||
data={tableData}
|
||||
isLoading={valuesQuery.isLoading}
|
||||
isFetching={valuesQuery.isFetching}
|
||||
error={valuesQuery.error}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
minHeight="300px"
|
||||
>
|
||||
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap padding="2" alignItems="start">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
import { Column, ComboBox, Grid, Label, ListItem, Select } from '@umami/react-zen';
|
||||
import { Column, ComboBox, Grid, Label, ListItem, Row, Select } from '@umami/react-zen';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useEventDataPropertiesQuery, useMessages } from '@/components/hooks';
|
||||
import type { EventPropertyFilter } from '@/lib/types';
|
||||
import { EventDataFilterBar } from '../event-data/EventDataFilterBar';
|
||||
import { EventDataFilterButton } from '../event-data/EventDataFilterButton';
|
||||
import { EventDataNumericChart } from '../event-data/EventDataNumericChart';
|
||||
import { EventDataPivotTable } from '../event-data/EventDataPivotTable';
|
||||
import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart';
|
||||
@@ -10,6 +13,7 @@ import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart';
|
||||
export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const [eventFilters, setEventFilters] = useState<EventPropertyFilter[]>([]);
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
|
||||
@@ -45,6 +49,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
const handleEventChange = (value: string) => {
|
||||
setEventName(value);
|
||||
setPropertyName('');
|
||||
setEventFilters([]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,39 +62,52 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
>
|
||||
<Column gap="6" minWidth="0">
|
||||
{data && (
|
||||
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.event)}</Label>
|
||||
<Select
|
||||
value={eventName}
|
||||
onChange={handleEventChange}
|
||||
placeholder={t(labels.selectEvent)}
|
||||
maxHeight={480}
|
||||
>
|
||||
{eventNames.map(name => (
|
||||
<ListItem key={name} id={name}>
|
||||
{name}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.property)}</Label>
|
||||
<ComboBox
|
||||
inputValue={propertyName}
|
||||
onInputChange={setPropertyName}
|
||||
isDisabled={!eventName}
|
||||
allowsCustomValue
|
||||
allowsEmptyCollection
|
||||
>
|
||||
{properties.map((field: { propertyName: string }) => (
|
||||
<ListItem key={field.propertyName} id={field.propertyName}>
|
||||
{field.propertyName}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Row gap alignItems="end" marginBottom="3" wrap="wrap">
|
||||
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap style={{ flex: 1 }}>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.event)}</Label>
|
||||
<Select
|
||||
value={eventName}
|
||||
onChange={handleEventChange}
|
||||
placeholder={t(labels.selectEvent)}
|
||||
maxHeight={480}
|
||||
>
|
||||
{eventNames.map(name => (
|
||||
<ListItem key={name} id={name}>
|
||||
{name}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.property)}</Label>
|
||||
<ComboBox
|
||||
inputValue={propertyName}
|
||||
onInputChange={setPropertyName}
|
||||
isDisabled={!eventName}
|
||||
allowsCustomValue
|
||||
allowsEmptyCollection
|
||||
>
|
||||
{properties.map((field: { propertyName: string }) => (
|
||||
<ListItem key={field.propertyName} id={field.propertyName}>
|
||||
{field.propertyName}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
</Column>
|
||||
</Grid>
|
||||
{eventName && (
|
||||
<EventDataFilterButton
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
eventFilters={eventFilters}
|
||||
onApply={setEventFilters}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{eventName && (
|
||||
<EventDataFilterBar filters={eventFilters} onChange={setEventFilters} />
|
||||
)}
|
||||
{eventName && propertyName && (
|
||||
<Column border="bottom" paddingBottom="6">
|
||||
@@ -98,6 +116,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
propertyName={propertyName}
|
||||
eventFilters={eventFilters}
|
||||
/>
|
||||
)}
|
||||
{selectedProperty?.dataType === 2 && (
|
||||
@@ -105,11 +124,18 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
propertyName={propertyName}
|
||||
eventFilters={eventFilters}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
)}
|
||||
{eventName && <EventDataPivotTable websiteId={websiteId} eventName={eventName} />}
|
||||
{eventName && (
|
||||
<EventDataPivotTable
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
eventFilters={eventFilters}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { parseEventPropertyFilters } from '@/lib/params';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams } from '@/lib/schema';
|
||||
@@ -32,7 +33,8 @@ export async function GET(
|
||||
|
||||
const { eventName, propertyName, metric, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters);
|
||||
const eventFilters = parseEventPropertyFilters(query);
|
||||
const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters, eventFilters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { parseEventPropertyFilters } from '@/lib/params';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams } from '@/lib/schema';
|
||||
@@ -31,7 +32,8 @@ export async function GET(
|
||||
|
||||
const { eventName, propertyName, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters);
|
||||
const eventFilters = parseEventPropertyFilters(query);
|
||||
const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters, eventFilters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { parseEventPropertyFilters } from '@/lib/params';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams, pagingParams } from '@/lib/schema';
|
||||
@@ -31,7 +32,8 @@ export async function GET(
|
||||
|
||||
const { eventName, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const result = await getEventDataPivot(websiteId, eventName, filters);
|
||||
const eventFilters = parseEventPropertyFilters(query);
|
||||
const result = await getEventDataPivot(websiteId, eventName, filters, eventFilters);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { serializeEventPropertyFilters } from '@/lib/params';
|
||||
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
@@ -8,6 +9,7 @@ export function useEventDataNumericSeriesQuery(
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
@@ -17,7 +19,7 @@ export function useEventDataNumericSeriesQuery(
|
||||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot:numeric-series',
|
||||
{ websiteId, eventName, propertyName, metric, startAt, endAt, unit, timezone, ...params },
|
||||
{ websiteId, eventName, propertyName, metric, eventFilters, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot/numeric-series`, {
|
||||
@@ -28,6 +30,7 @@ export function useEventDataNumericSeriesQuery(
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...serializeEventPropertyFilters(eventFilters),
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { serializeEventPropertyFilters } from '@/lib/params';
|
||||
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
@@ -6,6 +7,7 @@ import { useFilterParameters } from '../useFilterParameters';
|
||||
export function useEventDataPivotQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
@@ -15,7 +17,7 @@ export function useEventDataPivotQuery(
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot',
|
||||
{ websiteId, eventName, startAt, endAt, unit, timezone, ...params },
|
||||
{ websiteId, eventName, eventFilters, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot`, {
|
||||
@@ -24,6 +26,7 @@ export function useEventDataPivotQuery(
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...serializeEventPropertyFilters(eventFilters),
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { serializeEventPropertyFilters } from '@/lib/params';
|
||||
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
@@ -7,6 +8,7 @@ export function useEventDataPropertySeriesQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
@@ -16,7 +18,7 @@ export function useEventDataPropertySeriesQuery(
|
||||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot:property-series',
|
||||
{ websiteId, eventName, propertyName, startAt, endAt, unit, timezone, ...params },
|
||||
{ websiteId, eventName, propertyName, eventFilters, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot/property-series`, {
|
||||
@@ -26,6 +28,7 @@ export function useEventDataPropertySeriesQuery(
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...serializeEventPropertyFilters(eventFilters),
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
|
||||
@@ -58,8 +58,9 @@ export function FilterBar({ websiteId }: { websiteId?: string }) {
|
||||
justifyContent="space-between"
|
||||
padding="2"
|
||||
backgroundColor="surface-sunken"
|
||||
wrap="wrap"
|
||||
>
|
||||
<Row alignItems="center" gap="2" wrap="wrap">
|
||||
<Row alignItems="center" gap="2" wrap="wrap" width={{ base: '100%', md: 'auto' }}>
|
||||
{segment && !isLoading && (
|
||||
<FilterItem
|
||||
name="segment"
|
||||
@@ -149,7 +150,7 @@ const FilterItem = ({ name, label, operator, value, onRemove }) => {
|
||||
theme="dark"
|
||||
>
|
||||
<Row alignItems="center" gap="4">
|
||||
<Row alignItems="center" gap="2" maxWidth={'500px'}>
|
||||
<Row alignItems="center" gap="2" style={{ maxWidth: 'min(500px, calc(100vw - 10rem))', minWidth: 0, overflow: 'hidden' }}>
|
||||
<Text color="primary" weight="bold">
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -190,6 +190,7 @@ export const labels: Record<string, string> = {
|
||||
type: 'label.type',
|
||||
filter: 'label.filter',
|
||||
filters: 'label.filters',
|
||||
propertyFilter: 'label.property-filter',
|
||||
breakdown: 'label.breakdown',
|
||||
true: 'label.true',
|
||||
false: 'label.false',
|
||||
|
||||
+76
-2
@@ -1,10 +1,10 @@
|
||||
import { CLICKHOUSE } from '@/lib/db';
|
||||
import { type ClickHouseClient, createClient } from '@clickhouse/client';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import debug from 'debug';
|
||||
import { CLICKHOUSE } from '@/lib/db';
|
||||
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
|
||||
import { filtersObjectToArray } from './params';
|
||||
import type { QueryFilters, QueryOptions } from './types';
|
||||
import type { EventPropertyFilter, QueryFilters, QueryOptions } from './types';
|
||||
|
||||
export const CLICKHOUSE_DATE_FORMATS = {
|
||||
utc: '%Y-%m-%dT%H:%i:%SZ',
|
||||
@@ -235,6 +235,79 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
|
||||
sql: string;
|
||||
params: Record<string, any>;
|
||||
} {
|
||||
if (!filters.length) return { sql: '', params: {} };
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
filters.forEach(({ propertyName, dataType, operator, value }, i) => {
|
||||
const keyParam = `epf_key_${i}`;
|
||||
const valParam = `epf_val_${i}`;
|
||||
params[keyParam] = propertyName;
|
||||
|
||||
const isNumeric = dataType === 2;
|
||||
const col = isNumeric ? 'number_value' : 'string_value';
|
||||
|
||||
let condition: string;
|
||||
if (isNumeric) {
|
||||
params[valParam] = parseFloat(value) || 0;
|
||||
const opMap: Record<string, string> = {
|
||||
eq: `${col} = {${valParam}:Float64}`,
|
||||
neq: `${col} != {${valParam}:Float64}`,
|
||||
gt: `${col} > {${valParam}:Float64}`,
|
||||
lt: `${col} < {${valParam}:Float64}`,
|
||||
gte: `${col} >= {${valParam}:Float64}`,
|
||||
lte: `${col} <= {${valParam}:Float64}`,
|
||||
};
|
||||
condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`;
|
||||
} else if (operator === 'eq' || operator === 'neq') {
|
||||
const vals = value.split(',').filter(Boolean);
|
||||
if (!vals.length) return;
|
||||
params[valParam] = vals;
|
||||
condition = mapFilter(
|
||||
col,
|
||||
operator === 'eq' ? OPERATORS.equals : OPERATORS.notEquals,
|
||||
valParam,
|
||||
'String',
|
||||
);
|
||||
} else if (operator === 'regex' || operator === 'notRegex') {
|
||||
if (!value) return;
|
||||
params[valParam] = value;
|
||||
condition = mapFilter(
|
||||
col,
|
||||
operator === 'regex' ? OPERATORS.regex : OPERATORS.notRegex,
|
||||
valParam,
|
||||
'String',
|
||||
);
|
||||
} else {
|
||||
if (!value) return;
|
||||
params[valParam] = value;
|
||||
condition = mapFilter(
|
||||
col,
|
||||
operator === 'c' ? OPERATORS.contains : OPERATORS.doesNotContain,
|
||||
valParam,
|
||||
'String',
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(`and event_id in (
|
||||
select event_id
|
||||
from event_data
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and data_key = {${keyParam}:String}
|
||||
and data_type = ${dataType}
|
||||
and ${condition}
|
||||
)`);
|
||||
});
|
||||
|
||||
return { sql: parts.join('\n'), params };
|
||||
}
|
||||
|
||||
async function pagedRawQuery(
|
||||
query: string,
|
||||
queryParams: Record<string, any>,
|
||||
@@ -321,6 +394,7 @@ export default {
|
||||
getDateSQL,
|
||||
getSearchSQL,
|
||||
getFilterQuery,
|
||||
getEventPropertyFilterQuery,
|
||||
getUTCString,
|
||||
parseFilters,
|
||||
pagedRawQuery,
|
||||
|
||||
+29
-1
@@ -1,5 +1,5 @@
|
||||
import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
|
||||
import type { Filter, QueryFilters, QueryOptions } from '@/lib/types';
|
||||
import type { EventPropertyFilter, Filter, QueryFilters, QueryOptions } from '@/lib/types';
|
||||
|
||||
export function parseFilterValue(param: any) {
|
||||
if (typeof param === 'string') {
|
||||
@@ -88,3 +88,31 @@ export function filtersArrayToObject(filters: Filter[]) {
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function parseEventPropertyFilters(query: Record<string, any>): EventPropertyFilter[] {
|
||||
return Object.entries(query)
|
||||
.filter(([key]) => /^epf_/.test(key))
|
||||
.flatMap(([key, val]) => {
|
||||
const dotIndex = (val as string).indexOf('.');
|
||||
if (dotIndex < 1) return [];
|
||||
const withoutPrefix = key.slice(4); // strip "epf_"
|
||||
const propertyName = withoutPrefix.replace(/\d+$/, ''); // strip trailing index digits
|
||||
const operator = (val as string).slice(0, dotIndex);
|
||||
const value = (val as string).slice(dotIndex + 1);
|
||||
const isNumeric =
|
||||
['gt', 'lt', 'gte', 'lte'].includes(operator) ||
|
||||
(['eq', 'neq'].includes(operator) && value !== '' && !Number.isNaN(Number(value)));
|
||||
return [{ propertyName, dataType: isNumeric ? 2 : 1, operator, value }];
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeEventPropertyFilters(filters: EventPropertyFilter[]): Record<string, string> {
|
||||
const counts: Record<string, number> = {};
|
||||
return Object.fromEntries(
|
||||
filters.map(f => {
|
||||
const n = counts[f.propertyName] ?? 0;
|
||||
counts[f.propertyName] = n + 1;
|
||||
return [`epf_${f.propertyName}${n > 0 ? n : ''}`, `${f.operator}.${f.value}`];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
+65
-2
@@ -1,10 +1,10 @@
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||
import debug from 'debug';
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants';
|
||||
import { filtersObjectToArray } from './params';
|
||||
import type { Operator, QueryFilters, QueryOptions } from './types';
|
||||
import type { EventPropertyFilter, Operator, QueryFilters, QueryOptions } from './types';
|
||||
|
||||
const log = debug('umami:prisma');
|
||||
|
||||
@@ -252,6 +252,68 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
|
||||
sql: string;
|
||||
params: Record<string, any>;
|
||||
} {
|
||||
if (!filters.length) return { sql: '', params: {} };
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
filters.forEach(({ propertyName, dataType, operator, value }, i) => {
|
||||
const keyParam = `epf_key_${i}`;
|
||||
const valParam = `epf_val_${i}`;
|
||||
params[keyParam] = propertyName;
|
||||
|
||||
const isNumeric = dataType === 2;
|
||||
const col = isNumeric ? 'cast(number_value as decimal)' : 'string_value';
|
||||
|
||||
let condition: string;
|
||||
if (isNumeric) {
|
||||
params[valParam] = parseFloat(value) || 0;
|
||||
const opMap: Record<string, string> = {
|
||||
eq: `${col} = {{${valParam}}}`,
|
||||
neq: `${col} != {{${valParam}}}`,
|
||||
gt: `${col} > {{${valParam}}}`,
|
||||
lt: `${col} < {{${valParam}}}`,
|
||||
gte: `${col} >= {{${valParam}}}`,
|
||||
lte: `${col} <= {{${valParam}}}`,
|
||||
};
|
||||
condition = opMap[operator] ?? `${col} = {{${valParam}}}`;
|
||||
} else if (operator === 'eq' || operator === 'neq') {
|
||||
const vals = value.split(',').filter(Boolean);
|
||||
if (!vals.length) return;
|
||||
params[valParam] = vals;
|
||||
condition =
|
||||
operator === 'eq'
|
||||
? `${col} = ANY({{${valParam}::text[]}})`
|
||||
: `${col} != ALL({{${valParam}::text[]}})`;
|
||||
} else if (operator === 'regex' || operator === 'notRegex') {
|
||||
if (!value) return;
|
||||
params[valParam] = value;
|
||||
condition = operator === 'regex' ? `${col} ~* {{${valParam}}}` : `${col} !~* {{${valParam}}}`;
|
||||
} else {
|
||||
if (!value) return;
|
||||
params[valParam] = `%${value}%`;
|
||||
condition =
|
||||
operator === 'c' ? `${col} ilike {{${valParam}}}` : `${col} not ilike {{${valParam}}}`;
|
||||
}
|
||||
|
||||
parts.push(`and website_event.event_id in (
|
||||
select website_event_id
|
||||
from event_data
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
and data_key = {{${keyParam}}}
|
||||
and data_type = ${dataType}
|
||||
and ${condition}
|
||||
)`);
|
||||
});
|
||||
|
||||
return { sql: parts.join('\n'), params };
|
||||
}
|
||||
|
||||
async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> {
|
||||
if (process.env.LOG_QUERY) {
|
||||
log('QUERY:\n', sql);
|
||||
@@ -426,6 +488,7 @@ export default {
|
||||
getDateSQL,
|
||||
getDateWeeklySQL,
|
||||
getFilterQuery,
|
||||
getEventPropertyFilterQuery,
|
||||
getSearchParameters,
|
||||
getTimestampDiffSQL,
|
||||
getSearchSQL,
|
||||
|
||||
+4
-4
@@ -1,5 +1,3 @@
|
||||
import { startOfMonth, subMonths } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
import { checkAuth } from '@/lib/auth';
|
||||
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
|
||||
import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
|
||||
@@ -8,6 +6,8 @@ import { filtersArrayToObject } from '@/lib/params';
|
||||
import { badRequest, unauthorized } from '@/lib/response';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import { getWebsiteSegment } from '@/queries/prisma';
|
||||
import { startOfMonth, subMonths } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function parseRequest(
|
||||
request: Request,
|
||||
@@ -30,9 +30,9 @@ export async function parseRequest(
|
||||
} else if (isGet) {
|
||||
query = result.data;
|
||||
|
||||
// Re-add suffixed filter params (e.g., browser1, os2) stripped by Zod schema
|
||||
// Re-add dynamic params stripped by Zod schema: suffixed filter params (browser1, os2)
|
||||
for (const key of Object.keys(rawQuery)) {
|
||||
if (/\d+$/.test(key) && !(key in query)) {
|
||||
if ((/\d+$/.test(key) || /^epf_/.test(key)) && !(key in query)) {
|
||||
query[key] = rawQuery[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export interface Auth {
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventPropertyFilter {
|
||||
propertyName: string;
|
||||
dataType: number;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
name: string;
|
||||
operator: Operator;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataNumericSeries';
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function getEventDataNumericSeries(
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
eventFilters?: EventPropertyFilter[],
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
@@ -26,13 +27,15 @@ async function relationalQuery(
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
) {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
const aggFn = metric === 'avg' ? 'avg' : 'sum';
|
||||
|
||||
return rawQuery(
|
||||
@@ -53,10 +56,11 @@ async function relationalQuery(
|
||||
and event_data.data_key = {{propertyName}}
|
||||
and event_data.data_type = 2
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by 1
|
||||
order by 1
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
{ ...queryParams, eventName, propertyName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
@@ -67,10 +71,12 @@ async function clickhouseQuery(
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
): Promise<{ t: string; y: number }[]> {
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
||||
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
const aggFn = metric === 'avg' ? 'avg' : 'sum';
|
||||
|
||||
return rawQuery(
|
||||
@@ -95,10 +101,11 @@ async function clickhouseQuery(
|
||||
and event_data.data_key = {propertyName:String}
|
||||
and event_data.data_type = 2
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by t
|
||||
order by t
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
{ ...queryParams, eventName, propertyName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import clickhouse from '@/lib/clickhouse';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataPivot';
|
||||
|
||||
export async function getEventDataPivot(
|
||||
...args: [websiteId: string, eventName: string, filters: QueryFilters]
|
||||
...args: [
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
filters: QueryFilters,
|
||||
eventFilters?: EventPropertyFilter[],
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
@@ -15,8 +20,8 @@ export async function getEventDataPivot(
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters) {
|
||||
const { rawQuery, parseFilters } = prisma;
|
||||
async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) {
|
||||
const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma;
|
||||
const { page = 1, pageSize } = filters;
|
||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||
const offset = +size * (+page - 1);
|
||||
@@ -25,6 +30,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
|
||||
const countResult = await rawQuery(
|
||||
`
|
||||
@@ -39,8 +45,9 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_name = {{eventName}}
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
{ ...queryParams, eventName, ...epfParams },
|
||||
);
|
||||
|
||||
const count = countResult[0].num;
|
||||
@@ -59,6 +66,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_name = {{eventName}}
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by website_event.event_id
|
||||
order by max(website_event.created_at) desc
|
||||
limit ${size} offset ${offset}
|
||||
@@ -85,7 +93,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
order by website_event.created_at desc
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
{ ...queryParams, eventName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
@@ -106,13 +114,14 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
|
||||
return { data: [...eventMap.values()], count, page: +page, pageSize: size };
|
||||
}
|
||||
|
||||
async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters) {
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) {
|
||||
const { rawQuery, parseFilters, getEventPropertyFilterQuery } = clickhouse;
|
||||
const { page = 1, pageSize } = filters;
|
||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||
const offset = +size * (+page - 1);
|
||||
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
|
||||
const count = await rawQuery(
|
||||
`
|
||||
@@ -132,8 +141,9 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu
|
||||
where event_data_pivot.website_id = {websiteId:UUID}
|
||||
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
{ ...queryParams, eventName, ...epfParams },
|
||||
).then((res: any) => res[0].num);
|
||||
|
||||
const data = await rawQuery(
|
||||
@@ -161,11 +171,12 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu
|
||||
where event_data_pivot.website_id = {websiteId:UUID}
|
||||
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by event_id, session_id, event_name, url_path, created_at
|
||||
order by created_at desc
|
||||
limit ${size} offset ${offset}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
{ ...queryParams, eventName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataPropertySeries';
|
||||
|
||||
export async function getEventDataPropertySeries(
|
||||
...args: [websiteId: string, eventName: string, propertyName: string, filters: QueryFilters]
|
||||
...args: [
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
filters: QueryFilters,
|
||||
eventFilters?: EventPropertyFilter[],
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
@@ -19,13 +25,15 @@ async function relationalQuery(
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
filters: QueryFilters,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
) {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
@@ -46,10 +54,11 @@ async function relationalQuery(
|
||||
and event_data.data_key = {{propertyName}}
|
||||
and event_data.data_type = 1
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by 1, 2
|
||||
order by 2
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
{ ...queryParams, eventName, propertyName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
@@ -59,10 +68,12 @@ async function clickhouseQuery(
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
filters: QueryFilters,
|
||||
eventFilters: EventPropertyFilter[] = [],
|
||||
): Promise<{ x: string; t: string; y: number }[]> {
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
||||
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
@@ -87,10 +98,11 @@ async function clickhouseQuery(
|
||||
and event_data.data_key = {propertyName:String}
|
||||
and event_data.data_type = 1
|
||||
${filterQuery}
|
||||
${epfSQL}
|
||||
group by x, t
|
||||
order by t
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
{ ...queryParams, eventName, propertyName, ...epfParams },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user