diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts index ca2fd1b86..b4b9391b9 100644 --- a/src/components/hooks/useFilterParameters.ts +++ b/src/components/hooks/useFilterParameters.ts @@ -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 = {}; + + 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]); } diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts index 96397ea4d..57d9177cd 100644 --- a/src/components/hooks/useFilters.ts +++ b/src/components/hooks/useFilters.ts @@ -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, diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx index df2b4cd3f..0d4aaf035 100644 --- a/src/components/input/FieldFilters.tsx +++ b/src/components/input/FieldFilters.tsx @@ -48,24 +48,24 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field {} as Record, ); - const updateFilter = (name: string, props: Record) => { - onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); + const updateFilter = (index: number, props: Record) => { + 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 ( {groupFields.map(field => { - const isDisabled = !!value.find(({ name }) => name === field.name); return ( - + {field.filterLabel} ); @@ -115,9 +114,8 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field return ( {groupFields.map(field => { - const isDisabled = !!value.find(({ name }) => name === field.name); return ( - + {field.filterLabel} ); @@ -128,18 +126,18 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field - {value.map(filter => { + {value.map((filter, index) => { return ( handleSelect(index, operator)} + onRemove={() => handleRemove(index)} + onChange={(_name, val) => handleChange(index, val)} /> ); })} diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx index 0dca00b26..bc5537af2 100644 --- a/src/components/input/FilterBar.tsx +++ b/src/components/input/FilterBar.tsx @@ -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 ( diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index bb3976c71..cbe5022d1 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -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, 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) { function getQueryParams(filters: Record) { 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] diff --git a/src/lib/params.ts b/src/lib/params.ts index bbb52eb3b..f1452254d 100644 --- a/src/lib/params.ts +++ b/src/lib/params.ts @@ -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 = {}; 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; }, {}); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 3d31fcf9b..f10cc041f 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -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, 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, 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) { function getQueryParams(filters: Record) { 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) { } function parseFilters(filters: Record, 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_')), diff --git a/src/lib/request.ts b/src/lib/request.ts index e89f5722f..767288feb 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -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) { export function getRequestFilters(query: Record) { const result: Record = {}; - 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]; } } diff --git a/src/lib/types.ts b/src/lib/types.ts index c089e2303..e722195aa 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -31,6 +31,7 @@ export interface Filter { type?: string; column?: string; prefix?: string; + paramName?: string; } export interface DateRange {