diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/pages/pages.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/pages/pages.tsx index ef6ac836..8145910d 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/pages/pages.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/pages/pages.tsx @@ -39,7 +39,7 @@ export function Pages({ projectId }: { projectId: string }) { <> { setSearch(e.target.value); diff --git a/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx b/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx index a358581e..48f9aff7 100644 --- a/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx +++ b/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx @@ -59,7 +59,6 @@ export default async function Page({
- {/* */}
diff --git a/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx b/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx index 1af9f0b5..e1ea66c6 100644 --- a/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx +++ b/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx @@ -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')} > {getPropertyLabel(filter.name)} is - {filter.value[0]} + {filter.value.join(', ')} ); })} diff --git a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx index 0ac84a32..bd6fb227 100644 --- a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx +++ b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx @@ -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({ Overview filters -
- {enableEventsFilter && ( - +
+ {enableEventsFilter && ( + ({ + label: item.name, + value: item.name, + }))} + placeholder="Select event" + /> + )} + ({ - 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" /> - )} - { - setFilter(value, ''); - }} - value="" - placeholder="Filter by property" - label="What do you want to filter by?" - items={properties.map((item) => ({ - label: item, - value: item, - }))} - searchable - /> -
- -
+
{filters .filter((filter) => filter.value[0] !== null) .map((filter) => { return mode === 'events' ? ( - { + 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} /> ) : ( - + /* TODO: Implement profile filters */ + null ); })}
@@ -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, diff --git a/apps/dashboard/src/components/report/chart/Chart.tsx b/apps/dashboard/src/components/report/chart/Chart.tsx index 5f055f26..35fc2394 100644 --- a/apps/dashboard/src/components/report/chart/Chart.tsx +++ b/apps/dashboard/src/components/report/chart/Chart.tsx @@ -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 ; diff --git a/apps/dashboard/src/components/report/chart/ReportLineChart.tsx b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx index 3e542f63..f61787f8 100644 --- a/apps/dashboard/src/components/report/chart/ReportLineChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx @@ -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) => ( 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, // {item}, })); @@ -64,7 +63,7 @@ export function ReportBreakdowns() { }) ); }} - items={propertiesCombobox} + items={properties} placeholder="Select..." /> @@ -84,7 +83,7 @@ export function ReportBreakdowns() { }) ); }} - items={propertiesCombobox} + items={properties} placeholder="Select breakdown" />
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx index 12eb65b1..b5595745 100644 --- a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx +++ b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx @@ -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 ( -
+ /> + ); +} + +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 ( +
@@ -119,17 +188,78 @@ export function FilterItem({ filter, event }: FilterProps) { }))} label="Operator" > - - + {filter.operator === 'is' || filter.operator === 'isNot' ? ( + + ) : ( + changeFilterValue([value])} + /> + )} +
+
+ ); +} + +function FilterRawInput({ + value, + onChangeValue, +}: { + value: string; + onChangeValue: (value: string) => void; +}) { + const [internalValue, setInternalValue] = useState(value || ''); + + useEffect(() => { + if (value !== internalValue) { + setInternalValue(value); + } + }, [value]); + + return ( +
+ setInternalValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onChangeValue(internalValue); + } + }} + placeholder="Value" + size="default" + /> +
+ + {internalValue !== value && ( + onChangeValue(internalValue)} + > + + Press enter + + + + )} +
); diff --git a/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx index 0ff0dead..4e819eb1 100644 --- a/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx +++ b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx @@ -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 ( ({ + label: item, + value: item, + }))} onChange={(value) => { dispatch( changeEvent({ diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx index 5051c9cf..4877f82b 100644 --- a/apps/dashboard/src/components/ui/badge.tsx +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -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: { diff --git a/apps/dashboard/src/components/ui/input.tsx b/apps/dashboard/src/components/ui/input.tsx index 8c075cb3..4f190352 100644 --- a/apps/dashboard/src/components/ui/input.tsx +++ b/apps/dashboard/src/components/ui/input.tsx @@ -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', }, } ); diff --git a/apps/dashboard/src/hooks/useEventNames.ts b/apps/dashboard/src/hooks/useEventNames.ts index d224f886..93fcff47 100644 --- a/apps/dashboard/src/hooks/useEventNames.ts +++ b/apps/dashboard/src/hooks/useEventNames.ts @@ -3,6 +3,8 @@ import { api } from '@/trpc/client'; export function useEventNames( params: Parameters[0] ) { - const query = api.chart.events.useQuery(params); + const query = api.chart.events.useQuery(params, { + staleTime: 1000 * 60 * 10, + }); return query.data ?? []; } diff --git a/apps/dashboard/src/hooks/useEventProperties.ts b/apps/dashboard/src/hooks/useEventProperties.ts index bad5801b..31737b88 100644 --- a/apps/dashboard/src/hooks/useEventProperties.ts +++ b/apps/dashboard/src/hooks/useEventProperties.ts @@ -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[0] -) { - const query = api.chart.properties.useQuery(params); + params: RouterInputs['chart']['properties'], + options?: UseQueryOptions +): string[] { + const query = api.chart.properties.useQuery(params, { + staleTime: 1000 * 60 * 10, + enabled: options?.enabled ?? true, + }); return query.data ?? []; } diff --git a/apps/dashboard/src/hooks/useEventQueryFilters.ts b/apps/dashboard/src/hooks/useEventQueryFilters.ts index 0926fae1..7e29a35e 100644 --- a/apps/dashboard/src/hooks/useEventQueryFilters.ts +++ b/apps/dashboard/src/hooks/useEventQueryFilters.ts @@ -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, }, ]; }); diff --git a/apps/dashboard/src/hooks/useProfileValues.ts b/apps/dashboard/src/hooks/useProfileValues.ts index fe0964f0..181181cd 100644 --- a/apps/dashboard/src/hooks/useProfileValues.ts +++ b/apps/dashboard/src/hooks/useProfileValues.ts @@ -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 ?? []; } diff --git a/apps/dashboard/src/hooks/useEventValues.ts b/apps/dashboard/src/hooks/usePropertyValues.ts similarity index 52% rename from apps/dashboard/src/hooks/useEventValues.ts rename to apps/dashboard/src/hooks/usePropertyValues.ts index e54dbf0c..ce544a29 100644 --- a/apps/dashboard/src/hooks/useEventValues.ts +++ b/apps/dashboard/src/hooks/usePropertyValues.ts @@ -1,8 +1,11 @@ import { api } from '@/trpc/client'; -export function useEventValues( +export function usePropertyValues( params: Parameters[0] ) { - const query = api.chart.values.useQuery(params); + const query = api.chart.values.useQuery(params, { + staleTime: 1000 * 60 * 10, + }); + return query.data?.values ?? []; } diff --git a/packages/constants/index.ts b/packages/constants/index.ts index fba70c97..34acc98a 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -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 = { diff --git a/packages/db/src/clickhouse-client.ts b/packages/db/src/clickhouse-client.ts index 62968f92..0d63b8b7 100644 --- a/packages/db/src/clickhouse-client.ts +++ b/packages/db/src/clickhouse-client.ts @@ -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 diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 3c6eb27b..824c1cbc 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -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; + } } } }); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 967074af..15398908 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -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());