enable using same filters

This commit is contained in:
Francis Cao 2026-02-21 14:56:29 -08:00
parent b4b3ba5552
commit 79c06787cd
9 changed files with 125 additions and 145 deletions

View file

@ -1,91 +1,28 @@
import { useMemo } from 'react';
import { FILTER_COLUMNS } from '@/lib/constants';
import { useNavigation } from './useNavigation';
export function useFilterParameters() {
const {
query: {
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
},
} = useNavigation();
const { query } = useNavigation();
return useMemo(() => {
const filterParams: Record<string, any> = {};
for (const key of Object.keys(query)) {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
filterParams[key] = query[key];
}
}
return {
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search,
segment,
cohort,
excludeBounce,
...filterParams,
search: query.search,
segment: query.segment,
cohort: query.cohort,
excludeBounce: query.excludeBounce,
page: query.page,
pageSize: query.pageSize,
};
}, [
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
]);
}, [query]);
}

View file

@ -74,10 +74,11 @@ export function useFilters() {
};
const filters = Object.keys(query).reduce((arr, key) => {
if (FILTER_COLUMNS[key]) {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
let operator = 'eq';
let value = safeDecodeURIComponent(query[key]);
const label = fields.find(({ name }) => name === key)?.label;
const label = fields.find(({ name }) => name === baseName)?.label;
const match = value.match(/^([a-z]+)\.(.*)/);
@ -88,6 +89,7 @@ export function useFilters() {
return arr.concat({
name: key,
type: baseName,
operator,
value,
label,

View file

@ -48,24 +48,24 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
{} as Record<FieldGroup, typeof fields>,
);
const updateFilter = (name: string, props: Record<string, any>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
const updateFilter = (index: number, props: Record<string, any>) => {
onChange(value.map((filter, i) => (i === index ? { ...filter, ...props } : filter)));
};
const handleAdd = (name: Key) => {
onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
};
const handleChange = (name: string, value: Key) => {
updateFilter(name, { value });
const handleChange = (index: number, val: Key) => {
updateFilter(index, { value: val });
};
const handleSelect = (name: string, operator: Key) => {
updateFilter(name, { operator });
const handleSelect = (index: number, operator: Key) => {
updateFilter(index, { operator });
};
const handleRemove = (name: string) => {
onChange(value.filter(filter => filter.name !== name));
const handleRemove = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
return (
@ -87,9 +87,8 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
return (
<MenuSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
<MenuItem key={field.name} id={field.name}>
{field.filterLabel}
</MenuItem>
);
@ -115,9 +114,8 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
return (
<ListSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
<ListItem key={field.name} id={field.name}>
{field.filterLabel}
</ListItem>
);
@ -128,18 +126,18 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
</List>
</Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
{value.map(filter => {
{value.map((filter, index) => {
return (
<FilterRecord
key={filter.name}
key={`${filter.name}-${index}`}
websiteId={websiteId}
type={filter.name}
startDate={startDate}
endDate={endDate}
{...filter}
onSelect={handleSelect}
onRemove={handleRemove}
onChange={handleChange}
onSelect={(_name, operator) => handleSelect(index, operator)}
onRemove={() => handleRemove(index)}
onChange={(_name, val) => handleChange(index, val)}
/>
);
})}

View file

@ -78,12 +78,12 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
/>
)}
{filters.map(filter => {
const { name, label, operator, value } = filter;
const { name, type, label, operator, value } = filter;
const paramValue = isSearchOperator(operator)
? value
: String(value)
.split(',')
.map(v => formatValue(v, name))
.map(v => formatValue(v, type || name))
.join(', ');
return (

View file

@ -70,14 +70,21 @@ function getSearchSQL(column: string, param: string = 'search'): string {
return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
}
function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
const value = `{${name}:${type}}`;
function mapFilter(
column: string,
operator: string,
name: string,
type: string = 'String',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{${param}:${type}}`;
switch (operator) {
case OPERATORS.equals:
return `${column} IN {${name}:Array(${type})}`;
return `${column} IN {${param}:Array(${type})}`;
case OPERATORS.notEquals:
return `${column} NOT IN {${name}:Array(${type})}`;
return `${column} NOT IN {${param}:Array(${type})}`;
case OPERATORS.contains:
return `positionCaseInsensitive(${column}, ${value}) > 0`;
case OPERATORS.doesNotContain:
@ -92,27 +99,30 @@ function mapFilter(column: string, operator: string, name: string, type: string
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
const isCohort = options?.isCohort;
const query = filtersObjectToArray(filters, options).reduce(
(arr, { name, column, operator, paramName }) => {
const isCohort = options?.isCohort;
if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)];
}
if (column) {
if (name === 'eventType') {
arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`);
} else {
arr.push(`and ${mapFilter(column, operator, name)}`);
if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)];
}
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
if (column) {
if (name === 'eventType') {
arr.push(`and ${mapFilter(column, operator, name, 'UInt32', paramName)}`);
} else {
arr.push(`and ${mapFilter(column, operator, name, 'String', paramName)}`);
}
return arr;
}, []);
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
return arr;
},
[],
);
return query.join('\n');
}
@ -177,13 +187,15 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) {
return {
...filters,
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value }) => {
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
const resolvedColumn =
column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
if (!resolvedColumn || !name || value === undefined) return obj;
obj[name] = ([OPERATORS.equals, OPERATORS.notEquals] as string[]).includes(operator)
const key = paramName ?? name;
obj[key] = ([OPERATORS.equals, OPERATORS.notEquals] as string[]).includes(operator)
? Array.isArray(value)
? value
: [value]

View file

@ -51,15 +51,23 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
return arr;
}
const baseName = key.replace(/\d+$/, '');
const paramName = key !== baseName ? key : undefined;
if (filter?.name && filter?.value !== undefined) {
return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] });
return arr.concat({
...filter,
column: options?.columns?.[baseName] ?? FILTER_COLUMNS[baseName],
paramName: paramName ?? filter.paramName,
});
}
const { operator, value } = parseFilterValue(filter);
return arr.concat({
name: key,
column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
name: baseName,
paramName,
column: options?.columns?.[baseName] ?? FILTER_COLUMNS[baseName],
operator,
value,
prefix: options?.prefix,
@ -68,10 +76,14 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
}
export function filtersArrayToObject(filters: Filter[]) {
const nameCounts: Record<string, number> = {};
return filters.reduce((obj, filter: Filter) => {
const { name, operator, value } = filter;
const count = nameCounts[name] ?? 0;
const key = count === 0 ? name : `${name}${count}`;
nameCounts[name] = count + 1;
obj[name] = `${operator}.${Array.isArray(value) ? value.join(',') : value}`;
obj[key] = `${operator}.${Array.isArray(value) ? value.join(',') : value}`;
return obj;
}, {});

View file

@ -71,8 +71,15 @@ function getSearchSQL(column: string, param: string = 'search'): string {
return `and ${column} ilike {{${param}}}`;
}
function mapFilter(column: string, operator: string, name: string, type: string = '') {
const value = `{{${name}${type ? `::${type}` : ''}}}`;
function mapFilter(
column: string,
operator: string,
name: string,
type: string = '',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{{${param}${type ? `::${type}` : ''}}}`;
if (name.startsWith('cohort_')) {
name = name.slice('cohort_'.length);
@ -100,7 +107,7 @@ function mapFilter(column: string, operator: string, name: string, type: string
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
const query = filtersObjectToArray(filters, options).reduce(
(arr, { name, column, operator, prefix = '' }) => {
(arr, { name, column, operator, prefix = '', paramName }) => {
const isCohort = options?.isCohort;
if (isCohort) {
@ -108,7 +115,7 @@ function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}
}
if (column) {
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name, '', paramName)}`);
if (name === 'referrer') {
arr.push(
@ -181,18 +188,20 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) {
return {
...filters,
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value }) => {
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
const resolvedColumn =
column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
if (!resolvedColumn) return obj;
const key = paramName ?? name;
if (([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator)) {
obj[name] = `%${value}%`;
obj[key] = `%${value}%`;
} else if (([OPERATORS.equals, OPERATORS.notEquals] as Operator[]).includes(operator)) {
obj[name] = Array.isArray(value) ? value : [value];
obj[key] = Array.isArray(value) ? value : [value];
} else {
obj[name] = value;
obj[key] = value;
}
return obj;
@ -201,9 +210,10 @@ function getQueryParams(filters: Record<string, any>) {
}
function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
const joinSession = Object.keys(filters).find(key =>
['referrer', ...SESSION_COLUMNS].includes(key),
);
const joinSession = Object.keys(filters).find(key => {
const baseName = key.replace(/\d+$/, '');
return ['referrer', ...SESSION_COLUMNS].includes(baseName);
});
const cohortFilters = Object.fromEntries(
Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),

View file

@ -22,12 +22,20 @@ export async function parseRequest(
if (schema) {
const isGet = request.method === 'GET';
const rawQuery = query;
const result = schema.safeParse(isGet ? query : body);
if (!result.success) {
error = () => badRequest(z.treeifyError(result.error));
} else if (isGet) {
query = result.data;
// Re-add suffixed filter params (e.g., browser1, os2) stripped by Zod schema
for (const key of Object.keys(rawQuery)) {
if (/\d+$/.test(key) && !(key in query)) {
query[key] = rawQuery[key];
}
}
} else {
body = result.data;
}
@ -71,10 +79,10 @@ export function getRequestDateRange(query: Record<string, string>) {
export function getRequestFilters(query: Record<string, any>) {
const result: Record<string, any> = {};
for (const key of Object.keys(FILTER_COLUMNS)) {
const value = query[key];
if (value !== undefined) {
result[key] = value;
for (const key of Object.keys(query)) {
const baseName = key.replace(/\d+$/, '');
if (baseName in FILTER_COLUMNS) {
result[key] = query[key];
}
}

View file

@ -31,6 +31,7 @@ export interface Filter {
type?: string;
column?: string;
prefix?: string;
paramName?: string;
}
export interface DateRange {