feat(dashboard): reuse reports filter on overview and add more operators (#31)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-09-05 15:55:31 +02:00
parent 7e941080dc
commit 18b3bc3018
20 changed files with 464 additions and 168 deletions

View File

@@ -39,7 +39,7 @@ export function Pages({ projectId }: { projectId: string }) {
<>
<TableButtons>
<Input
placeholder="Serch path"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);

View File

@@ -59,7 +59,6 @@ export default async function Page({
<div className="flex justify-between gap-2 p-4">
<div className="flex gap-2">
<OverviewReportRange />
{/* <OverviewFiltersDrawer projectId={projectId} mode="events" /> */}
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />

View File

@@ -46,10 +46,10 @@ export function OverviewFiltersButtons({
size="sm"
variant="outline"
icon={X}
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
onClick={() => setFilter(filter.name, [], 'is')}
>
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
<strong className="font-semibold">{filter.value[0]}</strong>
<strong className="font-semibold">{filter.value.join(', ')}</strong>
</Button>
);
})}

View File

@@ -1,3 +1,4 @@
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
@@ -8,9 +9,9 @@ import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventValues } from '@/hooks/useEventValues';
import { useProfileProperties } from '@/hooks/useProfileProperties';
import { useProfileValues } from '@/hooks/useProfileValues';
import { usePropertyValues } from '@/hooks/usePropertyValues';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -35,7 +36,7 @@ export function OverviewFiltersDrawerContent({
enableEventsFilter,
mode,
}: OverviewFiltersDrawerContentProps) {
const { interval, range } = useOverviewOptions();
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId, interval, range });
@@ -49,54 +50,65 @@ export function OverviewFiltersDrawerContent({
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4">
{enableEventsFilter && (
<ComboboxAdvanced
<div className="mt-8 flex flex-col rounded-md border bg-def-100">
<div className="flex flex-col gap-4 p-4">
{enableEventsFilter && (
<ComboboxAdvanced
className="w-full"
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}
<Combobox
className="w-full"
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
onChange={(value) => {
setFilter(value, [], 'is');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties
.filter((item) => item !== 'name')
.map((item) => ({
label: item,
value: item,
}))}
searchable
size="lg"
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, '');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties.map((item) => ({
label: item,
value: item,
}))}
searchable
/>
</div>
<div className="mt-8 flex flex-col gap-4">
</div>
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return mode === 'events' ? (
<FilterOptionEvent
<PureFilterItem
className="border-t p-4 first:border-0"
eventName="screen_view"
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
filter={filter}
range={range}
interval={interval}
onRemove={() => {
setFilter(filter.name, [], filter.operator);
}}
onChangeValue={(value) => {
setFilter(filter.name, value, filter.operator);
}}
onChangeOperator={(operator) => {
setFilter(filter.name, filter.value, operator);
}}
startDate={startDate}
endDate={endDate}
/>
) : (
<FilterOptionProfile
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
/* TODO: Implement profile filters */
null
);
})}
</div>
@@ -117,7 +129,7 @@ export function FilterOptionEvent({
) => void;
}) {
const { interval, range } = useOverviewOptions();
const values = useEventValues({
const values = usePropertyValues({
projectId,
event: filter.name === 'path' ? 'screen_view' : 'session_start',
property: filter.name,

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { api } from '@/trpc/client';
import debounce from 'lodash.debounce';
import type { IChartProps } from '@openpanel/validation';
@@ -16,7 +18,7 @@ import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartProps;
export function Chart() {
function useChartData() {
const {
interval,
events,
@@ -32,26 +34,78 @@ export function Chart() {
limit,
offset,
} = useChartContext();
const [data] = api.chart.chart.useSuspenseQuery(
{
const [debouncedParams, setDebouncedParams] = useState({
interval,
events,
breakdowns,
chartType,
range,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
});
const debouncedSetParams = useMemo(
() => debounce(setDebouncedParams, 500),
[]
);
useEffect(() => {
debouncedSetParams({
interval,
chartType,
events,
events: events.map((event) => ({
...event,
filters: event.filters?.filter((filter) => filter.value.length > 0),
})),
breakdowns,
chartType,
range,
startDate,
endDate,
projectId,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
},
{
keepPreviousData: true,
}
);
});
return () => {
debouncedSetParams.cancel();
};
}, [
interval,
events,
breakdowns,
chartType,
range,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
debouncedSetParams,
]);
const [data] = api.chart.chart.useSuspenseQuery(debouncedParams, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
return data;
}
export function Chart() {
const { chartType } = useChartContext();
const data = useChartData();
if (data.series.length === 0) {
return <ChartEmpty />;

View File

@@ -58,7 +58,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
range,
},
{
staleTime: 1000 * 60 * 5,
staleTime: 1000 * 60 * 10,
}
);
const formatDate = useFormatDateInterval(interval);
@@ -134,7 +134,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-def-200"
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine

View File

@@ -3,8 +3,8 @@
import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useEventProperties } from '@/hooks/useEventProperties';
import { useDispatch, useSelector } from '@/redux';
import { api } from '@/trpc/client';
import { SplitIcon } from 'lucide-react';
import type { IChartBreakdown } from '@openpanel/validation';
@@ -20,12 +20,11 @@ export function ReportBreakdowns() {
const range = useSelector((state) => state.report.range);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
const properties = useEventProperties({
projectId,
range,
interval,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
}).map((item) => ({
value: item,
label: item, // <RenderDots truncate>{item}</RenderDots>,
}));
@@ -64,7 +63,7 @@ export function ReportBreakdowns() {
})
);
}}
items={propertiesCombobox}
items={properties}
placeholder="Select..."
/>
<ReportBreakdownMore onClick={handleMore(item)} />
@@ -84,7 +83,7 @@ export function ReportBreakdowns() {
})
);
}}
items={propertiesCombobox}
items={properties}
placeholder="Select breakdown"
/>
</div>

View File

@@ -1,19 +1,26 @@
import { useEffect, useState } from 'react';
import { ColorSquare } from '@/components/color-square';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { usePropertyValues } from '@/hooks/usePropertyValues';
import { useDispatch, useSelector } from '@/redux';
import { api } from '@/trpc/client';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { RefreshCcwIcon, SlidersHorizontal, Trash } from 'lucide-react';
import { operators } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
IChartRange,
IInterval,
} from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
@@ -21,52 +28,53 @@ import { changeEvent } from '../../reportSlice';
interface FilterProps {
event: IChartEvent;
filter: IChartEvent['filters'][number];
filter: IChartEventFilter;
}
interface PureFilterProps {
eventName: string;
filter: IChartEventFilter;
range: IChartRange;
startDate: string | null;
endDate: string | null;
interval: IInterval;
onRemove: (filter: IChartEventFilter) => void;
onChangeValue: (
value: IChartEventFilterValue[],
filter: IChartEventFilter
) => void;
onChangeOperator: (
operator: IChartEventFilterOperator,
filter: IChartEventFilter
) => void;
className?: string;
}
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useAppParams();
const { range, startDate, endDate, interval } = useSelector(
(state) => state.report
);
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectId,
range,
interval,
startDate,
endDate,
});
const valuesCombobox =
potentialValues.data?.values?.map((item) => ({
value: item,
label: getLabel(item),
})) ?? [];
const removeFilter = () => {
const onRemove = ({ id }: IChartEventFilter) => {
dispatch(
changeEvent({
...event,
filters: event.filters.filter((item) => item.id !== filter.id),
filters: event.filters.filter((item) => item.id !== id),
})
);
};
const changeFilterValue = (
value: IChartEventFilterValue | IChartEventFilterValue[]
const onChangeValue = (
value: IChartEventFilterValue[],
{ id }: IChartEventFilter
) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
if (item.id === id) {
return {
...item,
value: Array.isArray(value) ? value : [value],
value,
};
}
@@ -76,14 +84,18 @@ export function FilterItem({ filter, event }: FilterProps) {
);
};
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
const onChangeOperator = (
operator: IChartEventFilterOperator,
{ id }: IChartEventFilter
) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
if (item.id === id) {
return {
...item,
value: item.value ? item.value.filter(Boolean).slice(0, 1) : [],
operator,
};
}
@@ -94,11 +106,68 @@ export function FilterItem({ filter, event }: FilterProps) {
);
};
const dispatch = useDispatch();
return (
<div
key={filter.name}
<PureFilterItem
filter={filter}
eventName={event.name}
range={range}
startDate={startDate}
endDate={endDate}
interval={interval}
onRemove={onRemove}
onChangeValue={onChangeValue}
onChangeOperator={onChangeOperator}
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-def-200 first:border-t"
>
/>
);
}
export function PureFilterItem({
filter,
eventName,
range,
startDate,
endDate,
interval,
onRemove,
onChangeValue,
onChangeOperator,
className,
}: PureFilterProps) {
const { projectId } = useAppParams();
const getLabel = useMappings();
const potentialValues = usePropertyValues({
event: eventName,
property: filter.name,
projectId,
range,
interval,
startDate,
endDate,
});
const valuesCombobox =
potentialValues.map((item) => ({
value: item,
label: getLabel(item),
})) ?? [];
const removeFilter = () => {
onRemove(filter);
};
const changeFilterValue = (value: IChartEventFilterValue[]) => {
onChangeValue(value, filter);
};
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
onChangeOperator(operator, filter);
};
return (
<div className={className}>
<div className="mb-2 flex items-center gap-2">
<ColorSquare className="bg-emerald-500">
<SlidersHorizontal size={10} />
@@ -119,17 +188,78 @@ export function FilterItem({ filter, event }: FilterProps) {
}))}
label="Operator"
>
<Button variant={'ghost'} className="whitespace-nowrap">
<Button
variant={'outline'}
className="whitespace-nowrap"
size="default"
>
{operators[filter.operator]}
</Button>
</DropdownMenuComposed>
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}
className="flex-1"
onChange={changeFilterValue}
placeholder="Select..."
/>
{filter.operator === 'is' || filter.operator === 'isNot' ? (
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}
className="flex-1"
onChange={changeFilterValue}
placeholder="Select..."
/>
) : (
<FilterRawInput
value={filter.value[0] ? String(filter.value[0]) : ''}
onChangeValue={(value) => changeFilterValue([value])}
/>
)}
</div>
</div>
);
}
function FilterRawInput({
value,
onChangeValue,
}: {
value: string;
onChangeValue: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(value || '');
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value);
}
}, [value]);
return (
<div className="relative w-full">
<Input
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
}
}}
placeholder="Value"
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChangeValue(internalValue)}
>
<Badge variant="muted">
Press enter
<RefreshCcwIcon className="ml-1 h-3 w-3" />
</Badge>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useEventProperties } from '@/hooks/useEventProperties';
import { useDispatch, useSelector } from '@/redux';
import { api } from '@/trpc/client';
import { FilterIcon } from 'lucide-react';
import { shortId } from '@openpanel/common';
@@ -21,7 +21,7 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
const endDate = useSelector((state) => state.report.endDate);
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
const properties = useEventProperties(
{
event: event.name,
projectId,
@@ -35,17 +35,15 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
}
);
const properties = (query.data ?? []).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
items={properties.map((item) => ({
label: item,
value: item,
}))}
onChange={(value) => {
dispatch(
changeEvent({

View File

@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'inline-flex h-[20px] items-center rounded-full border px-1.5 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex h-[20px] items-center rounded-full border px-2 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
@@ -19,6 +19,7 @@ const badgeVariants = cva(
success:
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
outline: 'text-foreground',
muted: 'bg-def-100 text-foreground',
},
},
defaultVariants: {

View File

@@ -10,12 +10,13 @@ const inputVariant = cva(
{
variants: {
size: {
default: 'h-8 px-3 py-2 ',
sm: 'h-8 px-3 py-2 ',
default: 'h-10 px-3 py-2 ',
large: 'h-12 px-4 py-3 text-lg',
},
},
defaultVariants: {
size: 'default',
size: 'sm',
},
}
);

View File

@@ -3,6 +3,8 @@ import { api } from '@/trpc/client';
export function useEventNames(
params: Parameters<typeof api.chart.events.useQuery>[0]
) {
const query = api.chart.events.useQuery(params);
const query = api.chart.events.useQuery(params, {
staleTime: 1000 * 60 * 10,
});
return query.data ?? [];
}

View File

@@ -1,9 +1,15 @@
import type { RouterInputs } from '@/trpc/client';
import { api } from '@/trpc/client';
import type { UseQueryOptions } from '@tanstack/react-query';
export function useEventProperties(
params: Parameters<typeof api.chart.properties.useQuery>[0]
) {
const query = api.chart.properties.useQuery(params);
params: RouterInputs['chart']['properties'],
options?: UseQueryOptions<RouterInputs['chart']['properties']>
): string[] {
const query = api.chart.properties.useQuery(params, {
staleTime: 1000 * 60 * 10,
enabled: options?.enabled ?? true,
});
return query.data ?? [];
}

View File

@@ -7,9 +7,9 @@ import {
useQueryState,
} from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
import type { IChartEventFilterOperator } from '@openpanel/validation';
type Operator = 'is' | 'isNot' | 'contains' | 'doesNotContain';
const nuqsOptions = { history: 'push' } as const;
export const eventQueryFiltersParser = createParser({
parse: (query: string) => {
@@ -22,8 +22,10 @@ export const eventQueryFiltersParser = createParser({
return {
id: key!,
name: key!,
operator: (operator ?? 'is') as Operator,
value: [decodeURIComponent(value!)],
operator: (operator ?? 'is') as IChartEventFilterOperator,
value: value
? value.split('|').map((v) => decodeURIComponent(v))
: [],
};
}) ?? []
);
@@ -32,7 +34,7 @@ export const eventQueryFiltersParser = createParser({
return value
.map(
(filter) =>
`${filter.id},${filter.operator},${encodeURIComponent(filter.value[0] ?? '')}`
`${filter.id},${filter.operator},${filter.value.map((v) => encodeURIComponent(v.trim())).join('|')}`
)
.join(';');
},
@@ -50,23 +52,36 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
const setFilter = useCallback(
(
name: string,
value: string | number | boolean | undefined | null,
operator: Operator = 'is'
value:
| string
| number
| boolean
| undefined
| null
| (string | number | boolean | undefined | null)[],
operator: IChartEventFilterOperator = 'is'
) => {
setFilters((prev) => {
const exists = prev.find((filter) => filter.name === name);
if (exists) {
// If same value is already set, remove the filter
if (exists.value[0] === value) {
return prev.filter((filter) => filter.name !== name);
}
const arrValue = Array.isArray(value) ? value : [value];
const newValue = value ? arrValue.map(String) : [];
// If nothing changes remove it
if (
newValue.length === 0 &&
exists?.value.length === 0 &&
exists.operator === operator
) {
return prev.filter((filter) => filter.name !== name);
}
if (exists) {
return prev.map((filter) => {
if (filter.name === name) {
return {
...filter,
operator,
value: [String(value)],
value: newValue,
};
}
return filter;
@@ -79,7 +94,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
id: name,
name,
operator,
value: [String(value)],
value: newValue,
},
];
});

View File

@@ -1,10 +1,15 @@
import { api } from '@/trpc/client';
export function useProfileValues(projectId: string, property: string) {
const query = api.profile.values.useQuery({
projectId: projectId,
property,
});
const query = api.profile.values.useQuery(
{
projectId: projectId,
property,
},
{
staleTime: 1000 * 60 * 10,
}
);
return query.data?.values ?? [];
}

View File

@@ -1,8 +1,11 @@
import { api } from '@/trpc/client';
export function useEventValues(
export function usePropertyValues(
params: Parameters<typeof api.chart.values.useQuery>[0]
) {
const query = api.chart.values.useQuery(params);
const query = api.chart.values.useQuery(params, {
staleTime: 1000 * 60 * 10,
});
return query.data?.values ?? [];
}

View File

@@ -67,6 +67,9 @@ export const operators = {
isNot: 'Is not',
contains: 'Contains',
doesNotContain: 'Not contains',
startsWith: 'Starts with',
endsWith: 'Ends with',
regex: 'Regex',
} as const;
export const chartTypes = {

View File

@@ -43,7 +43,8 @@ export const ch = new Proxy(originalCh, {
} catch (error: unknown) {
if (
error instanceof Error &&
(error.message.includes('socket hang up') ||
(error.message.includes('Connect') ||
error.message.includes('socket hang up') ||
error.message.includes('Timeout error'))
) {
console.info(
@@ -65,8 +66,7 @@ export const ch = new Proxy(originalCh, {
}
} else {
if (args[0].query) {
console.log('FAILED QUERY:');
console.log(args[0].query);
console.log('FAILED QUERY:', args[0].query);
}
// Handle other errors or rethrow them

View File

@@ -13,11 +13,26 @@ import {
} from '../clickhouse-client';
import { createSqlBuilder } from '../sql-builder';
function getPropertyKey(property: string) {
export function transformPropertyKey(property: string) {
if (property.startsWith('properties.')) {
if (property.includes('*')) {
return property
.replace(/^properties\./, '')
.replace('.*.', '.%.')
.replace(/\[\*\]$/, '.%')
.replace(/\[\*\].?/, '.%.');
}
return `properties['${property.replace(/^properties\./, '')}']`;
}
return property;
}
export function getSelectPropertyKey(property: string) {
if (property.startsWith('properties.')) {
if (property.includes('*')) {
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
transformPropertyKey(property)
)})))`;
}
return `properties['${property.replace(/^properties\./, '')}']`;
@@ -79,11 +94,11 @@ export function getChartSql({
}
if (breakdowns.length > 0 && limit) {
sb.where.bar = `(${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}) IN (
SELECT ${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (
SELECT ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
FROM ${TABLE_NAMES.events}
${getWhere()}
GROUP BY ${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}
GROUP BY ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
ORDER BY count(*) DESC
LIMIT ${limit}
)`;
@@ -91,7 +106,7 @@ export function getChartSql({
breakdowns.forEach((breakdown, index) => {
const key = `label_${index}`;
sb.select[key] = `${getPropertyKey(breakdown.name)} as ${key}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.groupBy[key] = `${key}`;
});
@@ -108,13 +123,13 @@ export function getChartSql({
}
if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(toFloat64(${getPropertyKey(event.property)})) as count`;
sb.where.property = `${getPropertyKey(event.property)} IS NOT NULL`;
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
}
if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(toFloat64(${getPropertyKey(event.property)})) as count`;
sb.where.property = `${getPropertyKey(event.property)} IS NOT NULL`;
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
}
if (event.segment === 'one_event_per_user') {
@@ -150,11 +165,9 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
}
if (name.startsWith('properties.')) {
const propertyKey = name
.replace(/^properties\./, '')
.replace('.*.', '.%.');
const propertyKey = getSelectPropertyKey(name);
const isWildcard = propertyKey.includes('%');
const whereFrom = getPropertyKey(name);
const whereFrom = getSelectPropertyKey(name);
switch (operator) {
case 'is': {
@@ -211,6 +224,48 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
}
break;
}
case 'startsWith': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x LIKE ${escape(`${String(val).trim()}%`)}`)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = value
.map(
(val) => `${whereFrom} LIKE ${escape(`${String(val).trim()}%`)}`
)
.join(' OR ');
}
break;
}
case 'endsWith': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x LIKE ${escape(`%${String(val).trim()}`)}`)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = value
.map(
(val) => `${whereFrom} LIKE ${escape(`%${String(val).trim()}`)}`
)
.join(' OR ');
}
break;
}
case 'regex': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `match(x, ${escape(String(val).trim())})`)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = value
.map(
(val) => `match(${whereFrom}, ${escape(String(val).trim())})`
)
.join(' OR ');
}
break;
}
}
} else {
switch (operator) {
@@ -240,6 +295,24 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
.join(' OR ');
break;
}
case 'startsWith': {
where[id] = value
.map((val) => `${name} LIKE ${escape(`${String(val).trim()}%`)}`)
.join(' OR ');
break;
}
case 'endsWith': {
where[id] = value
.map((val) => `${name} LIKE ${escape(`%${String(val).trim()}`)}`)
.join(' OR ');
break;
}
case 'regex': {
where[id] = value
.map((val) => `match(${name}, ${escape(String(val).trim())})`)
.join(' OR ');
break;
}
}
}
});

View File

@@ -7,6 +7,8 @@ import {
createSqlBuilder,
db,
formatClickhouseDate,
getPropertyKey,
getSelectPropertyKey,
TABLE_NAMES,
toDate,
} from '@openpanel/db';
@@ -126,14 +128,7 @@ export const chartRouter = createTRPCRouter({
if (event !== '*') {
sb.where.event = `name = ${escape(event)}`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
)}))) as values`;
} else {
sb.select.values = `distinct ${property} as values`;
}
sb.select.values = `distinct ${getSelectPropertyKey(property)} as values`;
sb.where.date = `${toDate('created_at', input.interval)} BETWEEN ${toDate(formatClickhouseDate(startDate), input.interval)} AND ${toDate(formatClickhouseDate(endDate), input.interval)};`;
const events = await chQuery<{ values: string[] }>(getSql());