feat(dashboard): reuse reports filter on overview and add more operators (#31)
This commit is contained in:
@@ -39,7 +39,7 @@ export function Pages({ projectId }: { projectId: string }) {
|
|||||||
<>
|
<>
|
||||||
<TableButtons>
|
<TableButtons>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Serch path"
|
placeholder="Search path"
|
||||||
value={search ?? ''}
|
value={search ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export default async function Page({
|
|||||||
<div className="flex justify-between gap-2 p-4">
|
<div className="flex justify-between gap-2 p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<OverviewReportRange />
|
<OverviewReportRange />
|
||||||
{/* <OverviewFiltersDrawer projectId={projectId} mode="events" /> */}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ServerLiveCounter projectId={projectId} />
|
<ServerLiveCounter projectId={projectId} />
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ export function OverviewFiltersButtons({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={X}
|
icon={X}
|
||||||
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
|
onClick={() => setFilter(filter.name, [], 'is')}
|
||||||
>
|
>
|
||||||
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
|
<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>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
@@ -8,9 +9,9 @@ import {
|
|||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
useEventQueryNamesFilter,
|
useEventQueryNamesFilter,
|
||||||
} from '@/hooks/useEventQueryFilters';
|
} from '@/hooks/useEventQueryFilters';
|
||||||
import { useEventValues } from '@/hooks/useEventValues';
|
|
||||||
import { useProfileProperties } from '@/hooks/useProfileProperties';
|
import { useProfileProperties } from '@/hooks/useProfileProperties';
|
||||||
import { useProfileValues } from '@/hooks/useProfileValues';
|
import { useProfileValues } from '@/hooks/useProfileValues';
|
||||||
|
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||||
import { XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
import type { Options as NuqsOptions } from 'nuqs';
|
import type { Options as NuqsOptions } from 'nuqs';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export function OverviewFiltersDrawerContent({
|
|||||||
enableEventsFilter,
|
enableEventsFilter,
|
||||||
mode,
|
mode,
|
||||||
}: OverviewFiltersDrawerContentProps) {
|
}: OverviewFiltersDrawerContentProps) {
|
||||||
const { interval, range } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||||
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
||||||
const eventNames = useEventNames({ projectId, interval, range });
|
const eventNames = useEventNames({ projectId, interval, range });
|
||||||
@@ -49,7 +50,8 @@ export function OverviewFiltersDrawerContent({
|
|||||||
<SheetTitle>Overview filters</SheetTitle>
|
<SheetTitle>Overview filters</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mt-8 flex flex-col rounded-md border bg-def-100">
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{enableEventsFilter && (
|
{enableEventsFilter && (
|
||||||
<ComboboxAdvanced
|
<ComboboxAdvanced
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -66,37 +68,47 @@ export function OverviewFiltersDrawerContent({
|
|||||||
<Combobox
|
<Combobox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setFilter(value, '');
|
setFilter(value, [], 'is');
|
||||||
}}
|
}}
|
||||||
value=""
|
value=""
|
||||||
placeholder="Filter by property"
|
placeholder="Filter by property"
|
||||||
label="What do you want to filter by?"
|
label="What do you want to filter by?"
|
||||||
items={properties.map((item) => ({
|
items={properties
|
||||||
|
.filter((item) => item !== 'name')
|
||||||
|
.map((item) => ({
|
||||||
label: item,
|
label: item,
|
||||||
value: item,
|
value: item,
|
||||||
}))}
|
}))}
|
||||||
searchable
|
searchable
|
||||||
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col gap-4">
|
|
||||||
{filters
|
{filters
|
||||||
.filter((filter) => filter.value[0] !== null)
|
.filter((filter) => filter.value[0] !== null)
|
||||||
.map((filter) => {
|
.map((filter) => {
|
||||||
return mode === 'events' ? (
|
return mode === 'events' ? (
|
||||||
<FilterOptionEvent
|
<PureFilterItem
|
||||||
|
className="border-t p-4 first:border-0"
|
||||||
|
eventName="screen_view"
|
||||||
key={filter.name}
|
key={filter.name}
|
||||||
projectId={projectId}
|
filter={filter}
|
||||||
setFilter={setFilter}
|
range={range}
|
||||||
{...filter}
|
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
|
/* TODO: Implement profile filters */
|
||||||
key={filter.name}
|
null
|
||||||
projectId={projectId}
|
|
||||||
setFilter={setFilter}
|
|
||||||
{...filter}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +129,7 @@ export function FilterOptionEvent({
|
|||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const { interval, range } = useOverviewOptions();
|
const { interval, range } = useOverviewOptions();
|
||||||
const values = useEventValues({
|
const values = usePropertyValues({
|
||||||
projectId,
|
projectId,
|
||||||
event: filter.name === 'path' ? 'screen_view' : 'session_start',
|
event: filter.name === 'path' ? 'screen_view' : 'session_start',
|
||||||
property: filter.name,
|
property: filter.name,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IChartProps } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ import { ReportPieChart } from './ReportPieChart';
|
|||||||
|
|
||||||
export type ReportChartProps = IChartProps;
|
export type ReportChartProps = IChartProps;
|
||||||
|
|
||||||
export function Chart() {
|
function useChartData() {
|
||||||
const {
|
const {
|
||||||
interval,
|
interval,
|
||||||
events,
|
events,
|
||||||
@@ -32,27 +34,79 @@ export function Chart() {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
} = useChartContext();
|
} = useChartContext();
|
||||||
const [data] = api.chart.chart.useSuspenseQuery(
|
|
||||||
{
|
const [debouncedParams, setDebouncedParams] = useState({
|
||||||
interval,
|
interval,
|
||||||
chartType,
|
|
||||||
events,
|
events,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
|
chartType,
|
||||||
range,
|
range,
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
projectId,
|
|
||||||
previous,
|
previous,
|
||||||
formula,
|
formula,
|
||||||
metric,
|
metric,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
const debouncedSetParams = useMemo(
|
||||||
}
|
() => debounce(setDebouncedParams, 500),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedSetParams({
|
||||||
|
interval,
|
||||||
|
events: events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
filters: event.filters?.filter((filter) => filter.value.length > 0),
|
||||||
|
})),
|
||||||
|
breakdowns,
|
||||||
|
chartType,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
formula,
|
||||||
|
metric,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
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) {
|
if (data.series.length === 0) {
|
||||||
return <ChartEmpty />;
|
return <ChartEmpty />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
|||||||
range,
|
range,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 10,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
@@ -134,7 +134,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
|||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
vertical={false}
|
vertical={false}
|
||||||
className="stroke-def-200"
|
className="stroke-border"
|
||||||
/>
|
/>
|
||||||
{references.data?.map((ref) => (
|
{references.data?.map((ref) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { useEventProperties } from '@/hooks/useEventProperties';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
|
||||||
import { SplitIcon } from 'lucide-react';
|
import { SplitIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { IChartBreakdown } from '@openpanel/validation';
|
import type { IChartBreakdown } from '@openpanel/validation';
|
||||||
@@ -20,12 +20,11 @@ export function ReportBreakdowns() {
|
|||||||
const range = useSelector((state) => state.report.range);
|
const range = useSelector((state) => state.report.range);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const propertiesQuery = api.chart.properties.useQuery({
|
const properties = useEventProperties({
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
range,
|
||||||
interval,
|
interval,
|
||||||
});
|
}).map((item) => ({
|
||||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
|
||||||
value: item,
|
value: item,
|
||||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||||
}));
|
}));
|
||||||
@@ -64,7 +63,7 @@ export function ReportBreakdowns() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
items={propertiesCombobox}
|
items={properties}
|
||||||
placeholder="Select..."
|
placeholder="Select..."
|
||||||
/>
|
/>
|
||||||
<ReportBreakdownMore onClick={handleMore(item)} />
|
<ReportBreakdownMore onClick={handleMore(item)} />
|
||||||
@@ -84,7 +83,7 @@ export function ReportBreakdowns() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
items={propertiesCombobox}
|
items={properties}
|
||||||
placeholder="Select breakdown"
|
placeholder="Select breakdown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { RenderDots } from '@/components/ui/RenderDots';
|
import { RenderDots } from '@/components/ui/RenderDots';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useMappings } from '@/hooks/useMappings';
|
import { useMappings } from '@/hooks/useMappings';
|
||||||
|
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
import { RefreshCcwIcon, SlidersHorizontal, Trash } from 'lucide-react';
|
||||||
|
|
||||||
import { operators } from '@openpanel/constants';
|
import { operators } from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
|
IChartEventFilter,
|
||||||
IChartEventFilterOperator,
|
IChartEventFilterOperator,
|
||||||
IChartEventFilterValue,
|
IChartEventFilterValue,
|
||||||
|
IChartRange,
|
||||||
|
IInterval,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { mapKeys } from '@openpanel/validation';
|
import { mapKeys } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -21,52 +28,53 @@ import { changeEvent } from '../../reportSlice';
|
|||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
event: IChartEvent;
|
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) {
|
export function FilterItem({ filter, event }: FilterProps) {
|
||||||
const { projectId } = useAppParams();
|
|
||||||
const { range, startDate, endDate, interval } = useSelector(
|
const { range, startDate, endDate, interval } = useSelector(
|
||||||
(state) => state.report
|
(state) => state.report
|
||||||
);
|
);
|
||||||
const getLabel = useMappings();
|
const onRemove = ({ id }: IChartEventFilter) => {
|
||||||
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 = () => {
|
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
filters: event.filters.filter((item) => item.id !== filter.id),
|
filters: event.filters.filter((item) => item.id !== id),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeFilterValue = (
|
const onChangeValue = (
|
||||||
value: IChartEventFilterValue | IChartEventFilterValue[]
|
value: IChartEventFilterValue[],
|
||||||
|
{ id }: IChartEventFilter
|
||||||
) => {
|
) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
filters: event.filters.map((item) => {
|
filters: event.filters.map((item) => {
|
||||||
if (item.id === filter.id) {
|
if (item.id === id) {
|
||||||
return {
|
return {
|
||||||
...item,
|
...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(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
filters: event.filters.map((item) => {
|
filters: event.filters.map((item) => {
|
||||||
if (item.id === filter.id) {
|
if (item.id === id) {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
value: item.value ? item.value.filter(Boolean).slice(0, 1) : [],
|
||||||
operator,
|
operator,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,11 +106,68 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
return (
|
return (
|
||||||
<div
|
<PureFilterItem
|
||||||
key={filter.name}
|
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"
|
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">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<ColorSquare className="bg-emerald-500">
|
<ColorSquare className="bg-emerald-500">
|
||||||
<SlidersHorizontal size={10} />
|
<SlidersHorizontal size={10} />
|
||||||
@@ -119,10 +188,15 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
}))}
|
}))}
|
||||||
label="Operator"
|
label="Operator"
|
||||||
>
|
>
|
||||||
<Button variant={'ghost'} className="whitespace-nowrap">
|
<Button
|
||||||
|
variant={'outline'}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
{operators[filter.operator]}
|
{operators[filter.operator]}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuComposed>
|
</DropdownMenuComposed>
|
||||||
|
{filter.operator === 'is' || filter.operator === 'isNot' ? (
|
||||||
<ComboboxAdvanced
|
<ComboboxAdvanced
|
||||||
items={valuesCombobox}
|
items={valuesCombobox}
|
||||||
value={filter.value}
|
value={filter.value}
|
||||||
@@ -130,6 +204,62 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
onChange={changeFilterValue}
|
onChange={changeFilterValue}
|
||||||
placeholder="Select..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { useEventProperties } from '@/hooks/useEventProperties';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
|
||||||
import { FilterIcon } from 'lucide-react';
|
import { FilterIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
@@ -21,7 +21,7 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
|||||||
const endDate = useSelector((state) => state.report.endDate);
|
const endDate = useSelector((state) => state.report.endDate);
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
|
|
||||||
const query = api.chart.properties.useQuery(
|
const properties = useEventProperties(
|
||||||
{
|
{
|
||||||
event: event.name,
|
event: event.name,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -35,17 +35,15 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const properties = (query.data ?? []).map((item) => ({
|
|
||||||
label: item,
|
|
||||||
value: item,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
searchable
|
searchable
|
||||||
placeholder="Select a filter"
|
placeholder="Select a filter"
|
||||||
value=""
|
value=""
|
||||||
items={properties}
|
items={properties.map((item) => ({
|
||||||
|
label: item,
|
||||||
|
value: item,
|
||||||
|
}))}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
|
|||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -19,6 +19,7 @@ const badgeVariants = cva(
|
|||||||
success:
|
success:
|
||||||
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
|
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
|
||||||
outline: 'text-foreground',
|
outline: 'text-foreground',
|
||||||
|
muted: 'bg-def-100 text-foreground',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ const inputVariant = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
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',
|
large: 'h-12 px-4 py-3 text-lg',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: 'default',
|
size: 'sm',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { api } from '@/trpc/client';
|
|||||||
export function useEventNames(
|
export function useEventNames(
|
||||||
params: Parameters<typeof api.chart.events.useQuery>[0]
|
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 ?? [];
|
return query.data ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import type { RouterInputs } from '@/trpc/client';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
|
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
export function useEventProperties(
|
export function useEventProperties(
|
||||||
params: Parameters<typeof api.chart.properties.useQuery>[0]
|
params: RouterInputs['chart']['properties'],
|
||||||
) {
|
options?: UseQueryOptions<RouterInputs['chart']['properties']>
|
||||||
const query = api.chart.properties.useQuery(params);
|
): string[] {
|
||||||
|
const query = api.chart.properties.useQuery(params, {
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
return query.data ?? [];
|
return query.data ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
useQueryState,
|
useQueryState,
|
||||||
} from 'nuqs';
|
} 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({
|
export const eventQueryFiltersParser = createParser({
|
||||||
parse: (query: string) => {
|
parse: (query: string) => {
|
||||||
@@ -22,8 +22,10 @@ export const eventQueryFiltersParser = createParser({
|
|||||||
return {
|
return {
|
||||||
id: key!,
|
id: key!,
|
||||||
name: key!,
|
name: key!,
|
||||||
operator: (operator ?? 'is') as Operator,
|
operator: (operator ?? 'is') as IChartEventFilterOperator,
|
||||||
value: [decodeURIComponent(value!)],
|
value: value
|
||||||
|
? value.split('|').map((v) => decodeURIComponent(v))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
@@ -32,7 +34,7 @@ export const eventQueryFiltersParser = createParser({
|
|||||||
return value
|
return value
|
||||||
.map(
|
.map(
|
||||||
(filter) =>
|
(filter) =>
|
||||||
`${filter.id},${filter.operator},${encodeURIComponent(filter.value[0] ?? '')}`
|
`${filter.id},${filter.operator},${filter.value.map((v) => encodeURIComponent(v.trim())).join('|')}`
|
||||||
)
|
)
|
||||||
.join(';');
|
.join(';');
|
||||||
},
|
},
|
||||||
@@ -50,23 +52,36 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
|
|||||||
const setFilter = useCallback(
|
const setFilter = useCallback(
|
||||||
(
|
(
|
||||||
name: string,
|
name: string,
|
||||||
value: string | number | boolean | undefined | null,
|
value:
|
||||||
operator: Operator = 'is'
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| undefined
|
||||||
|
| null
|
||||||
|
| (string | number | boolean | undefined | null)[],
|
||||||
|
operator: IChartEventFilterOperator = 'is'
|
||||||
) => {
|
) => {
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
const exists = prev.find((filter) => filter.name === name);
|
const exists = prev.find((filter) => filter.name === name);
|
||||||
if (exists) {
|
const arrValue = Array.isArray(value) ? value : [value];
|
||||||
// If same value is already set, remove the filter
|
const newValue = value ? arrValue.map(String) : [];
|
||||||
if (exists.value[0] === value) {
|
|
||||||
|
// If nothing changes remove it
|
||||||
|
if (
|
||||||
|
newValue.length === 0 &&
|
||||||
|
exists?.value.length === 0 &&
|
||||||
|
exists.operator === operator
|
||||||
|
) {
|
||||||
return prev.filter((filter) => filter.name !== name);
|
return prev.filter((filter) => filter.name !== name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
return prev.map((filter) => {
|
return prev.map((filter) => {
|
||||||
if (filter.name === name) {
|
if (filter.name === name) {
|
||||||
return {
|
return {
|
||||||
...filter,
|
...filter,
|
||||||
operator,
|
operator,
|
||||||
value: [String(value)],
|
value: newValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return filter;
|
return filter;
|
||||||
@@ -79,7 +94,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
|
|||||||
id: name,
|
id: name,
|
||||||
name,
|
name,
|
||||||
operator,
|
operator,
|
||||||
value: [String(value)],
|
value: newValue,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
export function useProfileValues(projectId: string, property: string) {
|
export function useProfileValues(projectId: string, property: string) {
|
||||||
const query = api.profile.values.useQuery({
|
const query = api.profile.values.useQuery(
|
||||||
|
{
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
property,
|
property,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return query.data?.values ?? [];
|
return query.data?.values ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
export function useEventValues(
|
export function usePropertyValues(
|
||||||
params: Parameters<typeof api.chart.values.useQuery>[0]
|
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 ?? [];
|
return query.data?.values ?? [];
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,9 @@ export const operators = {
|
|||||||
isNot: 'Is not',
|
isNot: 'Is not',
|
||||||
contains: 'Contains',
|
contains: 'Contains',
|
||||||
doesNotContain: 'Not contains',
|
doesNotContain: 'Not contains',
|
||||||
|
startsWith: 'Starts with',
|
||||||
|
endsWith: 'Ends with',
|
||||||
|
regex: 'Regex',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const chartTypes = {
|
export const chartTypes = {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export const ch = new Proxy(originalCh, {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
(error.message.includes('socket hang up') ||
|
(error.message.includes('Connect') ||
|
||||||
|
error.message.includes('socket hang up') ||
|
||||||
error.message.includes('Timeout error'))
|
error.message.includes('Timeout error'))
|
||||||
) {
|
) {
|
||||||
console.info(
|
console.info(
|
||||||
@@ -65,8 +66,7 @@ export const ch = new Proxy(originalCh, {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (args[0].query) {
|
if (args[0].query) {
|
||||||
console.log('FAILED QUERY:');
|
console.log('FAILED QUERY:', args[0].query);
|
||||||
console.log(args[0].query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle other errors or rethrow them
|
// Handle other errors or rethrow them
|
||||||
|
|||||||
@@ -13,11 +13,26 @@ import {
|
|||||||
} from '../clickhouse-client';
|
} from '../clickhouse-client';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
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.startsWith('properties.')) {
|
||||||
if (property.includes('*')) {
|
if (property.includes('*')) {
|
||||||
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
|
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
|
||||||
property.replace(/^properties\./, '').replace('.*.', '.%.')
|
transformPropertyKey(property)
|
||||||
)})))`;
|
)})))`;
|
||||||
}
|
}
|
||||||
return `properties['${property.replace(/^properties\./, '')}']`;
|
return `properties['${property.replace(/^properties\./, '')}']`;
|
||||||
@@ -79,11 +94,11 @@ export function getChartSql({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (breakdowns.length > 0 && limit) {
|
if (breakdowns.length > 0 && limit) {
|
||||||
sb.where.bar = `(${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}) IN (
|
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (
|
||||||
SELECT ${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}
|
SELECT ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
|
||||||
FROM ${TABLE_NAMES.events}
|
FROM ${TABLE_NAMES.events}
|
||||||
${getWhere()}
|
${getWhere()}
|
||||||
GROUP BY ${breakdowns.map((b) => getPropertyKey(b.name)).join(',')}
|
GROUP BY ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
|
||||||
ORDER BY count(*) DESC
|
ORDER BY count(*) DESC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
)`;
|
)`;
|
||||||
@@ -91,7 +106,7 @@ export function getChartSql({
|
|||||||
|
|
||||||
breakdowns.forEach((breakdown, index) => {
|
breakdowns.forEach((breakdown, index) => {
|
||||||
const key = `label_${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}`;
|
sb.groupBy[key] = `${key}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,13 +123,13 @@ export function getChartSql({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.segment === 'property_sum' && event.property) {
|
if (event.segment === 'property_sum' && event.property) {
|
||||||
sb.select.count = `sum(toFloat64(${getPropertyKey(event.property)})) as count`;
|
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getPropertyKey(event.property)} IS NOT NULL`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.segment === 'property_average' && event.property) {
|
if (event.segment === 'property_average' && event.property) {
|
||||||
sb.select.count = `avg(toFloat64(${getPropertyKey(event.property)})) as count`;
|
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getPropertyKey(event.property)} IS NOT NULL`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.segment === 'one_event_per_user') {
|
if (event.segment === 'one_event_per_user') {
|
||||||
@@ -150,11 +165,9 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name.startsWith('properties.')) {
|
if (name.startsWith('properties.')) {
|
||||||
const propertyKey = name
|
const propertyKey = getSelectPropertyKey(name);
|
||||||
.replace(/^properties\./, '')
|
|
||||||
.replace('.*.', '.%.');
|
|
||||||
const isWildcard = propertyKey.includes('%');
|
const isWildcard = propertyKey.includes('%');
|
||||||
const whereFrom = getPropertyKey(name);
|
const whereFrom = getSelectPropertyKey(name);
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case 'is': {
|
case 'is': {
|
||||||
@@ -211,6 +224,48 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
}
|
}
|
||||||
break;
|
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 {
|
} else {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
@@ -240,6 +295,24 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
.join(' OR ');
|
.join(' OR ');
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
|
getPropertyKey,
|
||||||
|
getSelectPropertyKey,
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
toDate,
|
toDate,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
@@ -126,14 +128,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
if (event !== '*') {
|
if (event !== '*') {
|
||||||
sb.where.event = `name = ${escape(event)}`;
|
sb.where.event = `name = ${escape(event)}`;
|
||||||
}
|
}
|
||||||
if (property.startsWith('properties.')) {
|
sb.select.values = `distinct ${getSelectPropertyKey(property)} as values`;
|
||||||
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.where.date = `${toDate('created_at', input.interval)} BETWEEN ${toDate(formatClickhouseDate(startDate), input.interval)} AND ${toDate(formatClickhouseDate(endDate), input.interval)};`;
|
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());
|
const events = await chQuery<{ values: string[] }>(getSql());
|
||||||
|
|||||||
Reference in New Issue
Block a user