From d05d9c4f34823831c2c3ec3b06569ab692ec5dd4 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Thu, 23 Jul 2026 11:50:35 -0700 Subject: [PATCH] Session and event property filtering. Closes #2945. Closes #4008. --- public/intl/messages/ar-SA.json | 1 + public/intl/messages/be-BY.json | 1 + public/intl/messages/bg-BG.json | 1 + public/intl/messages/bn-BD.json | 1 + public/intl/messages/bs-BA.json | 1 + public/intl/messages/ca-ES.json | 1 + public/intl/messages/cs-CZ.json | 1 + public/intl/messages/da-DK.json | 1 + public/intl/messages/de-CH.json | 1 + public/intl/messages/de-DE.json | 1 + public/intl/messages/el-GR.json | 1 + public/intl/messages/en-GB.json | 3 +- public/intl/messages/en-US.json | 3 +- public/intl/messages/es-ES.json | 1 + public/intl/messages/fa-IR.json | 1 + public/intl/messages/fi-FI.json | 1 + public/intl/messages/fo-FO.json | 1 + public/intl/messages/fr-FR.json | 1 + public/intl/messages/ga-ES.json | 1 + public/intl/messages/he-IL.json | 1 + public/intl/messages/hi-IN.json | 1 + public/intl/messages/hr-HR.json | 1 + public/intl/messages/hu-HU.json | 1 + public/intl/messages/id-ID.json | 1 + public/intl/messages/it-IT.json | 1 + public/intl/messages/ja-JP.json | 1 + public/intl/messages/km-KH.json | 1 + public/intl/messages/ko-KR.json | 1 + public/intl/messages/lt-LT.json | 1 + public/intl/messages/mn-MN.json | 1 + public/intl/messages/ms-MY.json | 1 + public/intl/messages/my-MM.json | 1 + public/intl/messages/nb-NO.json | 1 + public/intl/messages/nl-NL.json | 1 + public/intl/messages/pl-PL.json | 1 + public/intl/messages/pt-BR.json | 1 + public/intl/messages/pt-PT.json | 1 + public/intl/messages/ro-RO.json | 1 + public/intl/messages/ru-RU.json | 1 + public/intl/messages/si-LK.json | 1 + public/intl/messages/sk-SK.json | 1 + public/intl/messages/sl-SI.json | 1 + public/intl/messages/sv-SE.json | 1 + public/intl/messages/ta-IN.json | 1 + public/intl/messages/th-TH.json | 1 + public/intl/messages/tr-TR.json | 1 + public/intl/messages/uk-UA.json | 1 + public/intl/messages/ur-PK.json | 1 + public/intl/messages/uz-UZ.json | 1 + public/intl/messages/vi-VN.json | 1 + public/intl/messages/zh-CN.json | 1 + public/intl/messages/zh-TW.json | 1 + .../[websiteId]/event-data/values/route.ts | 2 +- .../hooks/queries/usePropertyFieldsQuery.ts | 4 +- .../hooks/queries/usePropertyValuesQuery.ts | 4 +- src/components/hooks/useFilterParameters.ts | 11 +- src/components/hooks/useFilters.ts | 17 +- src/components/hooks/useOperatorLabels.ts | 2 + src/components/input/FieldFilters.tsx | 19 +- src/components/input/FilterBar.tsx | 73 ++++- src/components/input/FilterEditForm.test.tsx | 64 +++++ src/components/input/FilterEditForm.tsx | 102 +++++-- src/components/input/WebsiteFilterButton.tsx | 34 ++- src/components/messages.ts | 1 + .../property-data/PropertyFilterEditForm.tsx | 115 +------- .../property-data/PropertyFilters.tsx | 162 +++++++++++ src/lib/clickhouse.ts | 263 ++++++++++++++++- src/lib/params.test.ts | 73 +++++ src/lib/params.ts | 112 +++++++- src/lib/prisma.ts | 270 +++++++++++++++++- src/lib/request.ts | 15 +- src/lib/types.ts | 3 + src/queries/sql/events/getEventDataValues.ts | 21 +- 73 files changed, 1262 insertions(+), 161 deletions(-) create mode 100644 src/components/input/FilterEditForm.test.tsx create mode 100644 src/components/property-data/PropertyFilters.tsx create mode 100644 src/lib/params.test.ts diff --git a/public/intl/messages/ar-SA.json b/public/intl/messages/ar-SA.json index 6c817eb79..6b8e147cc 100644 --- a/public/intl/messages/ar-SA.json +++ b/public/intl/messages/ar-SA.json @@ -114,6 +114,7 @@ "environment": "بيئة", "event": "الحدث", "event-data": "تاريخ الحدث", + "event-properties": "تاريخ الحدث", "event-name": "اسم الحدث", "events": "الأحداث", "exclude-bounce": "استبعاد الارتداد", diff --git a/public/intl/messages/be-BY.json b/public/intl/messages/be-BY.json index 3df2c2fa3..c9857e26d 100644 --- a/public/intl/messages/be-BY.json +++ b/public/intl/messages/be-BY.json @@ -114,6 +114,7 @@ "environment": "Асяроддзе", "event": "Падзея", "event-data": "Дадзеныя падзеі", + "event-properties": "Дадзеныя падзеі", "event-name": "Назва падзеі", "events": "Падзеі", "exclude-bounce": "Выключыць адмовы", diff --git a/public/intl/messages/bg-BG.json b/public/intl/messages/bg-BG.json index 89ae8be41..1d8b9b8b4 100644 --- a/public/intl/messages/bg-BG.json +++ b/public/intl/messages/bg-BG.json @@ -114,6 +114,7 @@ "environment": "Среда", "event": "Събитие", "event-data": "Данни за събитие", + "event-properties": "Данни за събитие", "event-name": "Име на събитие", "events": "Събития", "exclude-bounce": "Изключи отпадане", diff --git a/public/intl/messages/bn-BD.json b/public/intl/messages/bn-BD.json index 35c5f160a..8d601e97d 100644 --- a/public/intl/messages/bn-BD.json +++ b/public/intl/messages/bn-BD.json @@ -114,6 +114,7 @@ "environment": "পরিবেশ", "event": "ইভেন্ট", "event-data": "ইভেন্ট ডেটা", + "event-properties": "ইভেন্ট ডেটা", "event-name": "ইভেন্টের নাম", "events": "ঘটনা", "exclude-bounce": "বাউন্স বাদ দিন", diff --git a/public/intl/messages/bs-BA.json b/public/intl/messages/bs-BA.json index 4ecfe3754..21dda1915 100644 --- a/public/intl/messages/bs-BA.json +++ b/public/intl/messages/bs-BA.json @@ -115,6 +115,7 @@ "environment": "Okruženje", "event": "Događaj", "event-data": "Podaci o događaju", + "event-properties": "Podaci o događaju", "event-name": "Naziv događaja", "events": "Događaji", "exclude-bounce": "Isključi odbijanja", diff --git a/public/intl/messages/ca-ES.json b/public/intl/messages/ca-ES.json index b63429c90..916f0be10 100644 --- a/public/intl/messages/ca-ES.json +++ b/public/intl/messages/ca-ES.json @@ -115,6 +115,7 @@ "environment": "Entorn", "event": "Esdeveniment", "event-data": "Dades de l'esdeveniment", + "event-properties": "Dades de l'esdeveniment", "event-name": "Nom de l'esdeveniment", "events": "Esdeveniments", "exclude-bounce": "Excloure rebot", diff --git a/public/intl/messages/cs-CZ.json b/public/intl/messages/cs-CZ.json index 92a4a43a4..b36bc7213 100644 --- a/public/intl/messages/cs-CZ.json +++ b/public/intl/messages/cs-CZ.json @@ -115,6 +115,7 @@ "environment": "Prostředí", "event": "Událost", "event-data": "Data události", + "event-properties": "Data události", "event-name": "Název události", "events": "Události", "exclude-bounce": "Vyloučit odmítnutí", diff --git a/public/intl/messages/da-DK.json b/public/intl/messages/da-DK.json index 9bda981ef..62938d0d6 100644 --- a/public/intl/messages/da-DK.json +++ b/public/intl/messages/da-DK.json @@ -115,6 +115,7 @@ "environment": "Miljø", "event": "Hændelse", "event-data": "Hændelsesdata", + "event-properties": "Hændelsesdata", "event-name": "Hændelsesnavn", "events": "Hændelser", "exclude-bounce": "Ekskluder afvisning", diff --git a/public/intl/messages/de-CH.json b/public/intl/messages/de-CH.json index 1b41f8310..4e286d1af 100644 --- a/public/intl/messages/de-CH.json +++ b/public/intl/messages/de-CH.json @@ -115,6 +115,7 @@ "environment": "Umgebung", "event": "Ereigniss", "event-data": "Ereigniss Date", + "event-properties": "Ereigniss Date", "event-name": "Ereignissname", "events": "Ereigniss", "exclude-bounce": "Absprung ausschliessen", diff --git a/public/intl/messages/de-DE.json b/public/intl/messages/de-DE.json index 0eef1b012..56808312e 100644 --- a/public/intl/messages/de-DE.json +++ b/public/intl/messages/de-DE.json @@ -115,6 +115,7 @@ "environment": "Umgebung", "event": "Ereignis", "event-data": "Ereignisdaten", + "event-properties": "Ereignisdaten", "event-name": "Ereignisname", "events": "Ereignisse", "exclude-bounce": "Absprung ausschließen", diff --git a/public/intl/messages/el-GR.json b/public/intl/messages/el-GR.json index da2288e0b..fd630bf00 100644 --- a/public/intl/messages/el-GR.json +++ b/public/intl/messages/el-GR.json @@ -114,6 +114,7 @@ "environment": "Περιβάλλον", "event": "Συμβάν", "event-data": "Δεδομένα συμβάντος", + "event-properties": "Δεδομένα συμβάντος", "event-name": "Όνομα συμβάντος", "events": "Γεγονότα", "exclude-bounce": "Εξαίρεση αναπήδησης", diff --git a/public/intl/messages/en-GB.json b/public/intl/messages/en-GB.json index 1f6fccdf8..cc054f9f5 100644 --- a/public/intl/messages/en-GB.json +++ b/public/intl/messages/en-GB.json @@ -115,6 +115,7 @@ "environment": "Environment", "event": "Event", "event-data": "Event data", + "event-properties": "Event Properties", "event-name": "Event name", "events": "Events", "exclude-bounce": "Exclude bounce", @@ -293,7 +294,7 @@ "select-component": "Select component", "select-website": "Select website", "session": "Session", - "session-data": "Session data", + "session-data": "Session Properties", "sessions": "Sessions", "settings": "Settings", "share": "Share", diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 272823c86..378a8f714 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -116,6 +116,7 @@ "environment": "Environment", "event": "Event", "event-data": "Event data", + "event-properties": "Event Properties", "event-name": "Event name", "events": "Events", "exclude-bounce": "Exclude bounce", @@ -302,7 +303,7 @@ "select-event": "Select an event", "select-website": "Select website", "session": "Session", - "session-data": "Session data", + "session-data": "Session Properties", "sessions": "Sessions", "settings": "Settings", "share": "Share", diff --git a/public/intl/messages/es-ES.json b/public/intl/messages/es-ES.json index 920f24d09..a5a4f189e 100644 --- a/public/intl/messages/es-ES.json +++ b/public/intl/messages/es-ES.json @@ -116,6 +116,7 @@ "environment": "Entorno", "event": "Evento", "event-data": "Datos de evento", + "event-properties": "Datos de evento", "event-name": "Nombre del evento", "events": "Eventos", "exclude-bounce": "Excluir rebote", diff --git a/public/intl/messages/fa-IR.json b/public/intl/messages/fa-IR.json index 930f03a0d..702fa3b79 100644 --- a/public/intl/messages/fa-IR.json +++ b/public/intl/messages/fa-IR.json @@ -114,6 +114,7 @@ "environment": "محیط", "event": "رویداد", "event-data": "داده‌های رویداد", + "event-properties": "داده‌های رویداد", "event-name": "نام رویداد", "events": "رویدادها", "exclude-bounce": "حذف نرخ پرش", diff --git a/public/intl/messages/fi-FI.json b/public/intl/messages/fi-FI.json index 2ac2f5e2e..c5981c8d1 100644 --- a/public/intl/messages/fi-FI.json +++ b/public/intl/messages/fi-FI.json @@ -115,6 +115,7 @@ "environment": "Ympäristö", "event": "Tapahtuma", "event-data": "Tapahtumatiedot", + "event-properties": "Tapahtumatiedot", "event-name": "Tapahtuman nimi", "events": "Tapahtumat", "exclude-bounce": "Sulje poishyppäys pois", diff --git a/public/intl/messages/fo-FO.json b/public/intl/messages/fo-FO.json index 6f29bc9cf..fcba18f93 100644 --- a/public/intl/messages/fo-FO.json +++ b/public/intl/messages/fo-FO.json @@ -115,6 +115,7 @@ "environment": "Umhvørvi", "event": "Tiltak", "event-data": "Tiltaksdata", + "event-properties": "Tiltaksdata", "event-name": "Tiltaksnavn", "events": "Hendingar/tiltøk", "exclude-bounce": "Útiloka frástøyt", diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index 8a2173ba3..170a75249 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -116,6 +116,7 @@ "environment": "Environnement", "event": "Évènement", "event-data": "Données d'évènements", + "event-properties": "Données d'évènements", "event-name": "Nom de l'évènement", "events": "Évènements", "exclude-bounce": "Exclure le rebond", diff --git a/public/intl/messages/ga-ES.json b/public/intl/messages/ga-ES.json index eadff6499..b7e092e95 100644 --- a/public/intl/messages/ga-ES.json +++ b/public/intl/messages/ga-ES.json @@ -115,6 +115,7 @@ "environment": "Contorno", "event": "Evento", "event-data": "Datos do evento", + "event-properties": "Datos do evento", "event-name": "Nome do evento", "events": "Eventos", "exclude-bounce": "Excluír rebote", diff --git a/public/intl/messages/he-IL.json b/public/intl/messages/he-IL.json index eefcc6dfd..4689f6197 100644 --- a/public/intl/messages/he-IL.json +++ b/public/intl/messages/he-IL.json @@ -114,6 +114,7 @@ "environment": "סביבה", "event": "אירוע", "event-data": "נתוני אירוע", + "event-properties": "נתוני אירוע", "event-name": "שם האירוע", "events": "אירועים", "exclude-bounce": "החרג נטישה", diff --git a/public/intl/messages/hi-IN.json b/public/intl/messages/hi-IN.json index 0f91b8ec1..063002a41 100644 --- a/public/intl/messages/hi-IN.json +++ b/public/intl/messages/hi-IN.json @@ -114,6 +114,7 @@ "environment": "वातावरण", "event": "घटना", "event-data": "घटना डेटा", + "event-properties": "घटना डेटा", "event-name": "घटना नाम", "events": "स्पर्धाएँ", "exclude-bounce": "बाउंस को बाहर करें", diff --git a/public/intl/messages/hr-HR.json b/public/intl/messages/hr-HR.json index 41fb434b7..0ea330d9e 100644 --- a/public/intl/messages/hr-HR.json +++ b/public/intl/messages/hr-HR.json @@ -115,6 +115,7 @@ "environment": "Okruženje", "event": "Događaj", "event-data": "Podaci događaja", + "event-properties": "Podaci događaja", "event-name": "Naziv događaja", "events": "Događaji", "exclude-bounce": "Isključi odbijanja", diff --git a/public/intl/messages/hu-HU.json b/public/intl/messages/hu-HU.json index ef62a8691..ffa6c01a4 100644 --- a/public/intl/messages/hu-HU.json +++ b/public/intl/messages/hu-HU.json @@ -115,6 +115,7 @@ "environment": "Környezet", "event": "Esemény", "event-data": "Eseményadatok", + "event-properties": "Eseményadatok", "event-name": "Esemény neve", "events": "Események", "exclude-bounce": "Visszafordulás kizárása", diff --git a/public/intl/messages/id-ID.json b/public/intl/messages/id-ID.json index 4450576f9..ec9cc0824 100644 --- a/public/intl/messages/id-ID.json +++ b/public/intl/messages/id-ID.json @@ -115,6 +115,7 @@ "environment": "Lingkungan", "event": "Peristiwa", "event-data": "Data peristiwa", + "event-properties": "Data peristiwa", "event-name": "Nama peristiwa", "events": "Peristiwa", "exclude-bounce": "Kecualikan pantulan", diff --git a/public/intl/messages/it-IT.json b/public/intl/messages/it-IT.json index 13dd1bfb7..0929c98aa 100644 --- a/public/intl/messages/it-IT.json +++ b/public/intl/messages/it-IT.json @@ -115,6 +115,7 @@ "environment": "Ambiente", "event": "Evento", "event-data": "Dati evento", + "event-properties": "Dati evento", "event-name": "Nome evento", "events": "Eventi", "exclude-bounce": "Escludi rimbalzo", diff --git a/public/intl/messages/ja-JP.json b/public/intl/messages/ja-JP.json index 9f8097eb8..0427fc1d6 100644 --- a/public/intl/messages/ja-JP.json +++ b/public/intl/messages/ja-JP.json @@ -114,6 +114,7 @@ "environment": "環境", "event": "イベント", "event-data": "イベントデータ", + "event-properties": "イベントデータ", "event-name": "イベント名", "events": "イベント", "exclude-bounce": "直帰を除外", diff --git a/public/intl/messages/km-KH.json b/public/intl/messages/km-KH.json index 76b8ca0c2..7e6fdf5cd 100644 --- a/public/intl/messages/km-KH.json +++ b/public/intl/messages/km-KH.json @@ -114,6 +114,7 @@ "environment": "បរិស្ថាន", "event": "ព្រឹត្តិការណ៍", "event-data": "ទិន្នន័យព្រឹត្តិការណ៍", + "event-properties": "ទិន្នន័យព្រឹត្តិការណ៍", "event-name": "ឈ្មោះព្រឹត្តិការណ៍", "events": "ព្រឹត្តិការណ៍", "exclude-bounce": "មិនរាប់បញ្ចូលការលោតចេញ", diff --git a/public/intl/messages/ko-KR.json b/public/intl/messages/ko-KR.json index 3635dadef..4eddb077d 100644 --- a/public/intl/messages/ko-KR.json +++ b/public/intl/messages/ko-KR.json @@ -122,6 +122,7 @@ "environment": "환경", "event": "이벤트", "event-data": "이벤트 데이터", + "event-properties": "이벤트 데이터", "event-name": "이벤트 이름", "events": "이벤트", "exclude-bounce": "이탈 제외", diff --git a/public/intl/messages/lt-LT.json b/public/intl/messages/lt-LT.json index 5559460d6..fb2859912 100644 --- a/public/intl/messages/lt-LT.json +++ b/public/intl/messages/lt-LT.json @@ -115,6 +115,7 @@ "environment": "Aplinka", "event": "Įvykis", "event-data": "Įvykių duomenys", + "event-properties": "Įvykių duomenys", "event-name": "Įvykio pavadinimas", "events": "Įvykiai", "exclude-bounce": "Neįtraukti atmetimų", diff --git a/public/intl/messages/mn-MN.json b/public/intl/messages/mn-MN.json index b3bf38b98..c7fa77172 100644 --- a/public/intl/messages/mn-MN.json +++ b/public/intl/messages/mn-MN.json @@ -114,6 +114,7 @@ "environment": "Орчин", "event": "Үйлдэл", "event-data": "Үйлдлийн өгөгдөл", + "event-properties": "Үйлдлийн өгөгдөл", "event-name": "Үйлдлийн нэр", "events": "Үйлдэл", "exclude-bounce": "Буцалтыг хасах", diff --git a/public/intl/messages/ms-MY.json b/public/intl/messages/ms-MY.json index c8c5d0a47..9e16f6103 100644 --- a/public/intl/messages/ms-MY.json +++ b/public/intl/messages/ms-MY.json @@ -115,6 +115,7 @@ "environment": "Persekitaran", "event": "Peristiwa", "event-data": "Data peristiwa", + "event-properties": "Data peristiwa", "event-name": "Nama peristiwa", "events": "Peristiwa", "exclude-bounce": "Kecualikan lantunan", diff --git a/public/intl/messages/my-MM.json b/public/intl/messages/my-MM.json index 0e11dad4c..91438e654 100644 --- a/public/intl/messages/my-MM.json +++ b/public/intl/messages/my-MM.json @@ -114,6 +114,7 @@ "environment": "ပတ်ဝန်းကျင်", "event": "အဖြစ်အပျက်", "event-data": "အဖြစ်အပျက် ဒေတာ", + "event-properties": "အဖြစ်အပျက် ဒေတာ", "event-name": "အဖြစ်အပျက်အမည်", "events": "အဖြစ်အပျက်များ", "exclude-bounce": "Bounce ဖယ်ထုတ်မည်", diff --git a/public/intl/messages/nb-NO.json b/public/intl/messages/nb-NO.json index ae91c20c8..9a9e5f5d6 100644 --- a/public/intl/messages/nb-NO.json +++ b/public/intl/messages/nb-NO.json @@ -115,6 +115,7 @@ "environment": "Miljø", "event": "Hendelse", "event-data": "Hendelsesdata", + "event-properties": "Hendelsesdata", "event-name": "Hendelsesnavn", "events": "Hendelser", "exclude-bounce": "Ekskluder avvisning", diff --git a/public/intl/messages/nl-NL.json b/public/intl/messages/nl-NL.json index a6ebbc41d..22890ecc0 100644 --- a/public/intl/messages/nl-NL.json +++ b/public/intl/messages/nl-NL.json @@ -115,6 +115,7 @@ "environment": "Omgeving", "event": "Gebeurtenis", "event-data": "Datum gebeurtenis", + "event-properties": "Datum gebeurtenis", "event-name": "Gebeurtenisnaam", "events": "Gebeurtenissen", "exclude-bounce": "Bounce uitsluiten", diff --git a/public/intl/messages/pl-PL.json b/public/intl/messages/pl-PL.json index c58ee505b..ed5fc2ca2 100644 --- a/public/intl/messages/pl-PL.json +++ b/public/intl/messages/pl-PL.json @@ -115,6 +115,7 @@ "environment": "Środowisko", "event": "Zdarzenie", "event-data": "Dane zdarzenia", + "event-properties": "Dane zdarzenia", "event-name": "Nazwa zdarzenia", "events": "Zdarzenia", "exclude-bounce": "Wyklucz odrzucenia", diff --git a/public/intl/messages/pt-BR.json b/public/intl/messages/pt-BR.json index 55d1a158c..10fd97e80 100644 --- a/public/intl/messages/pt-BR.json +++ b/public/intl/messages/pt-BR.json @@ -115,6 +115,7 @@ "environment": "Ambiente", "event": "Evento", "event-data": "Dados do evento", + "event-properties": "Dados do evento", "event-name": "Nome do evento", "events": "Tipos de eventos", "exclude-bounce": "Excluir rejeição", diff --git a/public/intl/messages/pt-PT.json b/public/intl/messages/pt-PT.json index cba9a33d1..4563ac42d 100644 --- a/public/intl/messages/pt-PT.json +++ b/public/intl/messages/pt-PT.json @@ -115,6 +115,7 @@ "environment": "Ambiente", "event": "Evento", "event-data": "Dados do evento", + "event-properties": "Dados do evento", "event-name": "Nome do evento", "events": "Eventos", "exclude-bounce": "Excluir rejeição", diff --git a/public/intl/messages/ro-RO.json b/public/intl/messages/ro-RO.json index 6e44c9d2a..373951bb7 100644 --- a/public/intl/messages/ro-RO.json +++ b/public/intl/messages/ro-RO.json @@ -115,6 +115,7 @@ "environment": "Mediu", "event": "Eveniment", "event-data": "Date despre eveniment", + "event-properties": "Date despre eveniment", "event-name": "Nume eveniment", "events": "Evenimente", "exclude-bounce": "Exclude respingere", diff --git a/public/intl/messages/ru-RU.json b/public/intl/messages/ru-RU.json index 2b649bb5b..c25489881 100644 --- a/public/intl/messages/ru-RU.json +++ b/public/intl/messages/ru-RU.json @@ -116,6 +116,7 @@ "environment": "Окружение", "event": "Событие", "event-data": "Данные о событии", + "event-properties": "Данные о событии", "event-name": "Название события", "events": "События", "exclude-bounce": "Исключить отказы", diff --git a/public/intl/messages/si-LK.json b/public/intl/messages/si-LK.json index 89bb298e8..917727e6f 100644 --- a/public/intl/messages/si-LK.json +++ b/public/intl/messages/si-LK.json @@ -114,6 +114,7 @@ "environment": "පරිසරය", "event": "සිදුවීම", "event-data": "සිදුවීම් දත්ත", + "event-properties": "සිදුවීම් දත්ත", "event-name": "සිදුවීම් නාමය", "events": "සිදුවීම්", "exclude-bounce": "ප්‍රතිචිප්ත බැහැර කරන්න", diff --git a/public/intl/messages/sk-SK.json b/public/intl/messages/sk-SK.json index 8bb215381..a6047d8b1 100644 --- a/public/intl/messages/sk-SK.json +++ b/public/intl/messages/sk-SK.json @@ -115,6 +115,7 @@ "environment": "Prostredie", "event": "Udalosť", "event-data": "Dáta udalosti", + "event-properties": "Dáta udalosti", "event-name": "Názov udalosti", "events": "Udalosti", "exclude-bounce": "Vylúčiť odmietnutia", diff --git a/public/intl/messages/sl-SI.json b/public/intl/messages/sl-SI.json index bdda7edb6..3ea63ab15 100644 --- a/public/intl/messages/sl-SI.json +++ b/public/intl/messages/sl-SI.json @@ -115,6 +115,7 @@ "environment": "Okolje", "event": "Dogodek", "event-data": "Podatki dogodka", + "event-properties": "Podatki dogodka", "event-name": "Ime dogodka", "events": "Dogodki", "exclude-bounce": "Izključi odbite", diff --git a/public/intl/messages/sv-SE.json b/public/intl/messages/sv-SE.json index 4d82f0a81..1c24fd4bf 100644 --- a/public/intl/messages/sv-SE.json +++ b/public/intl/messages/sv-SE.json @@ -115,6 +115,7 @@ "environment": "Miljö", "event": "Händelse", "event-data": "Händelsedata", + "event-properties": "Händelsedata", "event-name": "Händelsenamn", "events": "Händelser", "exclude-bounce": "Exkludera avvisning", diff --git a/public/intl/messages/ta-IN.json b/public/intl/messages/ta-IN.json index d5c953af8..a1284200c 100644 --- a/public/intl/messages/ta-IN.json +++ b/public/intl/messages/ta-IN.json @@ -114,6 +114,7 @@ "environment": "சூழல்", "event": "நிகழ்வு", "event-data": "நிகழ்வுத் தரவு", + "event-properties": "நிகழ்வுத் தரவு", "event-name": "நிகழ்வு பெயர்", "events": "நிகழ்வுகள்", "exclude-bounce": "துள்ளலை விலக்கு", diff --git a/public/intl/messages/th-TH.json b/public/intl/messages/th-TH.json index 2cf3298ce..e41792ecd 100644 --- a/public/intl/messages/th-TH.json +++ b/public/intl/messages/th-TH.json @@ -114,6 +114,7 @@ "environment": "สภาพแวดล้อม", "event": "เหตุการณ์", "event-data": "ข้อมูลเหตุการณ์", + "event-properties": "ข้อมูลเหตุการณ์", "event-name": "ชื่อเหตุการณ์", "events": "เหตุการณ์", "exclude-bounce": "ยกเว้นการตีกลับ", diff --git a/public/intl/messages/tr-TR.json b/public/intl/messages/tr-TR.json index 10d27ede5..140869919 100644 --- a/public/intl/messages/tr-TR.json +++ b/public/intl/messages/tr-TR.json @@ -115,6 +115,7 @@ "environment": "Ortam", "event": "Olay", "event-data": "Olay verisi", + "event-properties": "Olay verisi", "event-name": "Olay adı", "events": "Olaylar", "exclude-bounce": "Hemen çıkışı hariç tut", diff --git a/public/intl/messages/uk-UA.json b/public/intl/messages/uk-UA.json index 080fd9bb4..463af12cb 100644 --- a/public/intl/messages/uk-UA.json +++ b/public/intl/messages/uk-UA.json @@ -114,6 +114,7 @@ "environment": "Середовище", "event": "Подія", "event-data": "Дані події", + "event-properties": "Дані події", "event-name": "Назва події", "events": "Події", "exclude-bounce": "Виключити відмови", diff --git a/public/intl/messages/ur-PK.json b/public/intl/messages/ur-PK.json index 922aa72c3..26aa95db4 100644 --- a/public/intl/messages/ur-PK.json +++ b/public/intl/messages/ur-PK.json @@ -114,6 +114,7 @@ "environment": "ماحول", "event": "واقعہ", "event-data": "واقعہ ڈیٹا", + "event-properties": "واقعہ ڈیٹا", "event-name": "واقعہ کا نام", "events": "واقعات", "exclude-bounce": "اچھال خارج کریں", diff --git a/public/intl/messages/uz-UZ.json b/public/intl/messages/uz-UZ.json index 8252ada05..9b154b4c9 100644 --- a/public/intl/messages/uz-UZ.json +++ b/public/intl/messages/uz-UZ.json @@ -115,6 +115,7 @@ "environment": "Muhit", "event": "Hodisa", "event-data": "Hodisa ma'lumotlari", + "event-properties": "Hodisa ma'lumotlari", "event-name": "Hodisa nomi", "events": "Hodisalar", "exclude-bounce": "Chiqib ketishni istisno qilish", diff --git a/public/intl/messages/vi-VN.json b/public/intl/messages/vi-VN.json index d628de547..304475766 100644 --- a/public/intl/messages/vi-VN.json +++ b/public/intl/messages/vi-VN.json @@ -114,6 +114,7 @@ "environment": "Môi trường", "event": "Sự kiện", "event-data": "Dữ liệu sự kiện", + "event-properties": "Dữ liệu sự kiện", "event-name": "Tên sự kiện", "events": "Các sự kiện", "exclude-bounce": "Loại trừ thoát trang", diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index e23822c94..3f92b30c6 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -114,6 +114,7 @@ "environment": "环境", "event": "事件", "event-data": "事件数据", + "event-properties": "事件数据", "event-name": "事件名称", "events": "行为类别", "exclude-bounce": "排除跳出", diff --git a/public/intl/messages/zh-TW.json b/public/intl/messages/zh-TW.json index 9d0d3d12c..78672835b 100644 --- a/public/intl/messages/zh-TW.json +++ b/public/intl/messages/zh-TW.json @@ -114,6 +114,7 @@ "environment": "環境", "event": "事件", "event-data": "事件資料", + "event-properties": "事件資料", "event-name": "事件名稱", "events": "事件", "exclude-bounce": "排除跳出", diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts index 4d6679080..6f68e3920 100644 --- a/src/app/api/websites/[websiteId]/event-data/values/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts @@ -12,7 +12,7 @@ export async function GET( const schema = z.object({ startAt: z.coerce.number().int(), endAt: z.coerce.number().int(), - event: z.string(), + event: z.string().optional(), propertyName: z.string(), dataType: z.coerce.number().int().optional(), ...filterParams, diff --git a/src/components/hooks/queries/usePropertyFieldsQuery.ts b/src/components/hooks/queries/usePropertyFieldsQuery.ts index 48cfea9e7..d0c72dbaa 100644 --- a/src/components/hooks/queries/usePropertyFieldsQuery.ts +++ b/src/components/hooks/queries/usePropertyFieldsQuery.ts @@ -26,7 +26,7 @@ export function usePropertyFieldsQuery( ? `/websites/${websiteId}/event-data/fields` : `/websites/${websiteId}/session-data/properties`, { - ...(source === 'event' ? { event: eventName } : {}), + ...(source === 'event' && eventName ? { event: eventName } : {}), startAt, endAt, unit, @@ -34,7 +34,7 @@ export function usePropertyFieldsQuery( ...params, }, ), - enabled: !!(websiteId && (source === 'session' || eventName)), + enabled: !!websiteId, ...options, }); } diff --git a/src/components/hooks/queries/usePropertyValuesQuery.ts b/src/components/hooks/queries/usePropertyValuesQuery.ts index 5c0ea227d..13f717da5 100644 --- a/src/components/hooks/queries/usePropertyValuesQuery.ts +++ b/src/components/hooks/queries/usePropertyValuesQuery.ts @@ -40,7 +40,7 @@ export function usePropertyValuesQuery( ? `/websites/${websiteId}/event-data/values` : `/websites/${websiteId}/session-data/values`, { - ...(source === 'event' ? { event: eventName } : {}), + ...(source === 'event' && eventName ? { event: eventName } : {}), startAt, endAt, unit, @@ -51,7 +51,7 @@ export function usePropertyValuesQuery( ...params, }, ), - enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + enabled: !!(websiteId && propertyName), ...options, }); } diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts index c49d1a6bb..8d54b764c 100644 --- a/src/components/hooks/useFilterParameters.ts +++ b/src/components/hooks/useFilterParameters.ts @@ -8,9 +8,10 @@ export function useFilterParameters({ }: { includePagination?: boolean; } = {}) { - const { query } = useNavigation(); + const { pathname, query } = useNavigation(); const share = useShare(); const allowFilter = share?.parameters?.allowFilter !== false; + const isEventsPath = pathname.endsWith('/events'); return useMemo(() => { const filterParams: Record = {}; @@ -18,7 +19,11 @@ export function useFilterParameters({ if (allowFilter) { for (const key of Object.keys(query)) { const baseName = key.replace(/\d+$/, ''); - if (FILTER_COLUMNS[baseName]) { + if ( + FILTER_COLUMNS[baseName] || + /^spf\d+$/.test(key) || + (isEventsPath && /^epf\d+$/.test(key)) + ) { filterParams[key] = query[key]; } } @@ -39,5 +44,5 @@ export function useFilterParameters({ } return params; - }, [allowFilter, includePagination, query]); + }, [allowFilter, includePagination, isEventsPath, query]); } diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts index 94e2f8936..0072d47ae 100644 --- a/src/components/hooks/useFilters.ts +++ b/src/components/hooks/useFilters.ts @@ -1,4 +1,5 @@ import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import { parseSessionPropertyFilters, parseUniversalEventPropertyFilters } from '@/lib/params'; import { safeDecodeURIComponent } from '@/lib/url'; import { useShare } from './context/useShare'; import { useFields } from './useFields'; @@ -8,7 +9,7 @@ import { useOperatorLabels } from './useOperatorLabels'; export function useFilters() { const { t, labels } = useMessages(); - const { query } = useNavigation(); + const { pathname, query } = useNavigation(); const { fields } = useFields(); const operatorLabels = useOperatorLabels(); const share = useShare(); @@ -85,6 +86,9 @@ export function useFilters() { return arr; }, []) : []; + const eventPropertyFilters = + allowFilter && pathname.endsWith('/events') ? parseUniversalEventPropertyFilters(query) : []; + const sessionPropertyFilters = allowFilter ? parseSessionPropertyFilters(query) : []; const getFilters = (type: string) => { return ( @@ -96,5 +100,14 @@ export function useFilters() { ); }; - return { fields, operators, filters, operatorLabels, typeFilters, getFilters }; + return { + fields, + operators, + filters, + eventPropertyFilters, + sessionPropertyFilters, + operatorLabels, + typeFilters, + getFilters, + }; } diff --git a/src/components/hooks/useOperatorLabels.ts b/src/components/hooks/useOperatorLabels.ts index b92d92dff..271d29b93 100644 --- a/src/components/hooks/useOperatorLabels.ts +++ b/src/components/hooks/useOperatorLabels.ts @@ -11,6 +11,8 @@ export function useOperatorLabels(): Record { [OPERATORS.notSet]: t(labels.isNotSet), [OPERATORS.contains]: t(labels.contains), [OPERATORS.doesNotContain]: t(labels.doesNotContain), + [OPERATORS.regex]: t(labels.regexMatch), + [OPERATORS.notRegex]: t(labels.regexNotMatch), [OPERATORS.true]: t(labels.true), [OPERATORS.false]: t(labels.false), [OPERATORS.greaterThan]: t(labels.greaterThan), diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx index 7107aeb2f..9f0346c10 100644 --- a/src/components/input/FieldFilters.tsx +++ b/src/components/input/FieldFilters.tsx @@ -81,9 +81,9 @@ export function FieldFilters({ return ( - + - - - handleAdd(key.toString())} - style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} - > - {(fields as any[]).map(field => ( - - {field.propertyName} - - ))} - - - - - - handleAdd(key.toString())}> - {(fields as any[]).map(field => ( - - {field.propertyName} - - ))} - - - - {filters.map((filter, index) => ( - setFilters(prev => prev.map((item, i) => (i === index ? f : item)))} - onRemove={() => setFilters(prev => prev.filter((_, i) => i !== index))} - /> - ))} - {!filters.length && } - - + diff --git a/src/components/property-data/PropertyFilters.tsx b/src/components/property-data/PropertyFilters.tsx new file mode 100644 index 000000000..bea3d48e6 --- /dev/null +++ b/src/components/property-data/PropertyFilters.tsx @@ -0,0 +1,162 @@ +'use client'; +import { + Button, + Column, + Grid, + Icon, + List, + ListItem, + Menu, + MenuItem, + MenuTrigger, + Popover, + Row, +} from '@umami/react-zen'; +import { format } from 'date-fns'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useMobile, usePropertyFieldsQuery } from '@/components/hooks'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; +import { Plus } from '@/components/icons'; +import { DATA_TYPE, OPERATORS } from '@/lib/constants'; +import type { PropertyFilter } from '@/lib/types'; +import { PropertyFilterRecord } from './PropertyFilterRecord'; + +export function PropertyFilters({ + source, + websiteId, + eventName, + fields: providedFields, + value, + onChange, +}: { + source: PropertyDataSource; + websiteId: string; + eventName?: string; + fields?: Array<{ propertyName: string; dataType: number }>; + value: PropertyFilter[]; + onChange: (filters: PropertyFilter[]) => void; +}) { + const { t, messages } = useMessages(); + const { isMobile } = useMobile(); + const { data: queriedFields, isLoading, isFetching } = usePropertyFieldsQuery( + source, + websiteId, + eventName, + { + enabled: !providedFields, + }, + ); + const fields = providedFields ?? queriedFields ?? []; + + const handleAdd = (propertyName: string) => { + const field = (fields as any[]).find(f => f.propertyName === propertyName); + const dataType: number = field?.dataType ?? DATA_TYPE.string; + + onChange([ + ...value, + { + propertyName, + dataType, + operator: + dataType === DATA_TYPE.date + ? OPERATORS.before + : dataType === DATA_TYPE.array + ? OPERATORS.contains + : OPERATORS.equals, + value: + dataType === DATA_TYPE.boolean + ? 'true' + : dataType === DATA_TYPE.date + ? format(new Date(), 'yyyy-MM-dd') + : '', + }, + ]); + }; + + return ( + + + + + + + handleAdd(key.toString())} + style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} + > + {(fields as any[]).map(field => ( + + {field.propertyName} + + ))} + + + + + + handleAdd(key.toString())}> + {(fields as any[]).map(field => ( + + {field.propertyName} + + ))} + + + + {value.map((filter, index) => ( + + onChange(value.map((item, i) => (i === index ? nextFilter : item))) + } + onRemove={() => onChange(value.filter((_, i) => i !== index))} + /> + ))} + {!value.length && + (isMobile ? ( + + {t(messages.nothingSelected)} + + ) : ( + + ))} + + + + ); +} diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index bf9d72677..1e26d4354 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -234,11 +234,25 @@ function parseFilters(filters: Record, options?: QueryOptions) { const cohortFilters = Object.fromEntries( Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), ); + const { + sql: eventPropertyFilterQuery, + params: eventPropertyFilterParams, + } = getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone); + const { + sql: sessionPropertyFilterQuery, + params: sessionPropertyFilterParams, + } = getSessionPropertyFilterQuery((filters as QueryFilters).sessionPropertyFilters, filters.timezone); return { - filterQuery: getFilterQuery(filters, options), + filterQuery: [getFilterQuery(filters, options), eventPropertyFilterQuery, sessionPropertyFilterQuery] + .filter(Boolean) + .join('\n'), dateQuery: getDateQuery(filters), - queryParams: getQueryParams(filters), + queryParams: { + ...getQueryParams(filters), + ...eventPropertyFilterParams, + ...sessionPropertyFilterParams, + }, cohortQuery: getCohortQuery(cohortFilters), excludeBounceQuery: getExcludeBounceQuery(filters), }; @@ -368,6 +382,251 @@ function getPropertyFilterQuery( return { sql: parts.join('\n'), params }; } +function getEventPropertyFilterQuery( + filters: PropertyFilter[] = [], + timezone?: string, +): { + sql: string; + params: Record; +} { + if (!filters.length) { + return { sql: '', params: {} }; + } + + const clauses: string[] = []; + const keyRefs: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, index) => { + const keyParam = `epf_key_${index}`; + const valParam = `epf_val_${index}`; + params[keyParam] = propertyName; + keyRefs.push(`{${keyParam}:String}`); + + let condition: string; + + switch (dataType) { + case DATA_TYPE.number: { + const col = 'number_value'; + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + [OPERATORS.equals]: `${col} = {${valParam}:Float64}`, + [OPERATORS.notEquals]: `${col} != {${valParam}:Float64}`, + [OPERATORS.greaterThan]: `${col} > {${valParam}:Float64}`, + [OPERATORS.lessThan]: `${col} < {${valParam}:Float64}`, + [OPERATORS.greaterThanEquals]: `${col} >= {${valParam}:Float64}`, + [OPERATORS.lessThanEquals]: `${col} <= {${valParam}:Float64}`, + }; + condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`; + break; + } + case DATA_TYPE.date: { + if (!value) return; + params[valParam] = value; + const dateCol = timezone + ? `toDate(toTimezone(date_value, {timezone:String}))` + : `toDate(toTimezone(date_value, 'UTC'))`; + const opMap: Record = { + [OPERATORS.before]: `${dateCol} < {${valParam}:Date}`, + [OPERATORS.after]: `${dateCol} > {${valParam}:Date}`, + }; + condition = opMap[operator] ?? `${dateCol} = {${valParam}:Date}`; + break; + } + case DATA_TYPE.array: { + if (!value) return; + params[valParam] = value; + condition = + operator === OPERATORS.contains + ? `has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})` + : `not has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})`; + break; + } + default: { + const col = 'string_value'; + + if (EQUALITY_OPERATORS.includes(operator)) { + const vals = value.split(',').filter(Boolean); + + if (!vals.length) return; + + params[valParam] = vals; + condition = mapFilter( + col, + operator === OPERATORS.equals ? OPERATORS.equals : OPERATORS.notEquals, + valParam, + 'String', + ); + } else if (REGEX_OPERATORS.includes(operator)) { + if (!value) return; + + params[valParam] = value; + condition = mapFilter( + col, + operator === OPERATORS.regex ? OPERATORS.regex : OPERATORS.notRegex, + valParam, + 'String', + ); + } else { + if (!value) return; + + params[valParam] = value; + condition = mapFilter( + col, + operator === OPERATORS.contains ? OPERATORS.contains : OPERATORS.doesNotContain, + valParam, + 'String', + ); + } + break; + } + } + + clauses.push( + `max(if(data_key = {${keyParam}:String} and data_type = ${dataType} and ${condition}, 1, 0)) = 1`, + ); + }); + + if (!clauses.length) { + return { sql: '', params: {} }; + } + + return { + sql: `and website_event.event_id in ( + select event_id + from event_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and data_key in (${keyRefs.join(', ')}) + group by event_id + having ${clauses.join('\n and ')} + )`, + params, + }; +} + +function getSessionPropertyFilterQuery( + filters: PropertyFilter[] = [], + timezone?: string, +): { + sql: string; + params: Record; +} { + if (!filters.length) { + return { sql: '', params: {} }; + } + + const clauses: string[] = []; + const keyRefs: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, index) => { + const keyParam = `spf_key_${index}`; + const valParam = `spf_val_${index}`; + params[keyParam] = propertyName; + keyRefs.push(`{${keyParam}:String}`); + + let condition: string; + + switch (dataType) { + case DATA_TYPE.number: { + const col = 'number_value'; + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + [OPERATORS.equals]: `${col} = {${valParam}:Float64}`, + [OPERATORS.notEquals]: `${col} != {${valParam}:Float64}`, + [OPERATORS.greaterThan]: `${col} > {${valParam}:Float64}`, + [OPERATORS.lessThan]: `${col} < {${valParam}:Float64}`, + [OPERATORS.greaterThanEquals]: `${col} >= {${valParam}:Float64}`, + [OPERATORS.lessThanEquals]: `${col} <= {${valParam}:Float64}`, + }; + condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`; + break; + } + case DATA_TYPE.date: { + if (!value) return; + params[valParam] = value; + const dateCol = timezone + ? `toDate(toTimezone(date_value, {timezone:String}))` + : `toDate(toTimezone(date_value, 'UTC'))`; + const opMap: Record = { + [OPERATORS.before]: `${dateCol} < {${valParam}:Date}`, + [OPERATORS.after]: `${dateCol} > {${valParam}:Date}`, + }; + condition = opMap[operator] ?? `${dateCol} = {${valParam}:Date}`; + break; + } + case DATA_TYPE.array: { + if (!value) return; + params[valParam] = value; + condition = + operator === OPERATORS.contains + ? `has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})` + : `not has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})`; + break; + } + default: { + const col = 'string_value'; + + if (EQUALITY_OPERATORS.includes(operator)) { + const vals = value.split(',').filter(Boolean); + + if (!vals.length) return; + + params[valParam] = vals; + condition = mapFilter( + col, + operator === OPERATORS.equals ? OPERATORS.equals : OPERATORS.notEquals, + valParam, + 'String', + ); + } else if (REGEX_OPERATORS.includes(operator)) { + if (!value) return; + + params[valParam] = value; + condition = mapFilter( + col, + operator === OPERATORS.regex ? OPERATORS.regex : OPERATORS.notRegex, + valParam, + 'String', + ); + } else { + if (!value) return; + + params[valParam] = value; + condition = mapFilter( + col, + operator === OPERATORS.contains ? OPERATORS.contains : OPERATORS.doesNotContain, + valParam, + 'String', + ); + } + break; + } + } + + clauses.push( + `max(if(data_key = {${keyParam}:String} and data_type = ${dataType} and ${condition}, 1, 0)) = 1`, + ); + }); + + if (!clauses.length) { + return { sql: '', params: {} }; + } + + return { + sql: `and tuple(website_event.website_id, website_event.session_id) in ( + select website_id, session_id + from session_data final + where website_id = {websiteId:UUID} + and data_key in (${keyRefs.join(', ')}) + group by website_id, session_id + having ${clauses.join('\n and ')} + )`, + params, + }; +} + async function pagedRawQuery( query: string, queryParams: Record, diff --git a/src/lib/params.test.ts b/src/lib/params.test.ts new file mode 100644 index 000000000..5a8f8518d --- /dev/null +++ b/src/lib/params.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; +import { DATA_TYPE, OPERATORS } from './constants'; +import { + parseSessionPropertyFilters, + parseUniversalEventPropertyFilters, + serializeSessionPropertyFilters, + serializeUniversalEventPropertyFilters, +} from './params'; + +describe('session property filter params', () => { + test('serializes and parses session property filters', () => { + const filters = [ + { + propertyName: 'user.id', + dataType: DATA_TYPE.string, + operator: OPERATORS.equals, + value: 'abc.123,xyz', + }, + { + propertyName: 'created at', + dataType: DATA_TYPE.date, + operator: OPERATORS.before, + value: '2026-07-01', + }, + ]; + + const params = serializeSessionPropertyFilters(filters); + + expect(params).toEqual({ + spf0: '1.eq.user%2Eid.abc.123,xyz', + spf1: '4.bf.created%20at.2026-07-01', + }); + expect(parseSessionPropertyFilters(params)).toEqual(filters); + }); + + test('ignores malformed session property filters', () => { + expect( + parseSessionPropertyFilters({ + spf0: 'eq.user%2Eid.value', + spf1: '1.unknown.email.value', + spf2: '1.eq', + }), + ).toEqual([]); + }); + + test('serializes and parses universal event property filters', () => { + const filters = [ + { + propertyName: 'plan.id', + dataType: DATA_TYPE.string, + operator: OPERATORS.equals, + value: 'pro,team', + }, + ]; + + const params = serializeUniversalEventPropertyFilters(filters); + + expect(params).toEqual({ + epf0: '1.eq.plan%2Eid.pro,team', + }); + expect(parseUniversalEventPropertyFilters(params)).toEqual(filters); + }); + + test('ignores malformed universal event property filters', () => { + expect( + parseUniversalEventPropertyFilters({ + epf0: 'eq.plan%2Eid.value', + epf1: '1.unknown.email.value', + epf2: '1.eq', + }), + ).toEqual([]); + }); +}); diff --git a/src/lib/params.ts b/src/lib/params.ts index f924ee30a..9e85b4465 100644 --- a/src/lib/params.ts +++ b/src/lib/params.ts @@ -1,8 +1,18 @@ import { DATA_TYPE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; -import type { Filter, Operator, PropertyFilter, QueryFilters, QueryOptions } from '@/lib/types'; +import type { + Filter, + Operator, + PropertyFilter, + QueryFilters, + QueryOptions, + EventPropertyFilter, + SessionPropertyFilter, +} from '@/lib/types'; const VALID_OPERATORS: Operator[] = Object.values(OPERATORS); const VALID_EVENT_DATA_TYPES = Object.values(DATA_TYPE); +const EVENT_PROPERTY_FILTER_PARAM = /^epf\d+$/; +const SESSION_PROPERTY_FILTER_PARAM = /^spf\d+$/; function isValidEventDataType(value: number): value is (typeof VALID_EVENT_DATA_TYPES)[number] { return VALID_EVENT_DATA_TYPES.includes(value as (typeof VALID_EVENT_DATA_TYPES)[number]); @@ -108,6 +118,18 @@ function getPropertyFilterPrefix(key: string, prefixes: string[]) { return prefixes.find(prefix => key.startsWith(`${prefix}_`)); } +function decodePropertyName(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function encodePropertyName(value: string) { + return encodeURIComponent(value).replaceAll('.', '%2E'); +} + export function parsePropertyFilters( query: Record, prefixes: string[] = ['pf'], @@ -156,6 +178,76 @@ export function parseEventPropertyFilters(query: Record) { return parsePropertyFilters(query, ['pf']); } +export function parseUniversalEventPropertyFilters( + query: Record, +): EventPropertyFilter[] { + return Object.entries(query).flatMap(([key, val]) => { + if (!EVENT_PROPERTY_FILTER_PARAM.test(key)) { + return []; + } + + const stringValue = String(val); + const match = stringValue.match(/^(\d+)\.([^.]+)\.([^.]+)\.(.*)$/); + const explicitDataType = match ? Number(match[1]) : undefined; + const operator = resolveOperator(match?.[2]); + const propertyName = match?.[3] ? decodePropertyName(match[3]) : undefined; + const value = match?.[4]; + + if ( + !propertyName || + !operator || + explicitDataType === undefined || + !isValidEventDataType(explicitDataType) || + value === undefined + ) { + return []; + } + + return [ + { + propertyName, + dataType: explicitDataType, + operator, + value, + }, + ]; + }); +} + +export function parseSessionPropertyFilters(query: Record): SessionPropertyFilter[] { + return Object.entries(query).flatMap(([key, val]) => { + if (!SESSION_PROPERTY_FILTER_PARAM.test(key)) { + return []; + } + + const stringValue = String(val); + const match = stringValue.match(/^(\d+)\.([^.]+)\.([^.]+)\.(.*)$/); + const explicitDataType = match ? Number(match[1]) : undefined; + const operator = resolveOperator(match?.[2]); + const propertyName = match?.[3] ? decodePropertyName(match[3]) : undefined; + const value = match?.[4]; + + if ( + !propertyName || + !operator || + explicitDataType === undefined || + !isValidEventDataType(explicitDataType) || + value === undefined + ) { + return []; + } + + return [ + { + propertyName, + dataType: explicitDataType, + operator, + value, + }, + ]; + }); +} + export function serializePropertyFilters( filters: PropertyFilter[], prefix = 'pf', @@ -176,3 +268,21 @@ export function serializePropertyFilters( export function serializeEventPropertyFilters(filters: PropertyFilter[]) { return serializePropertyFilters(filters, 'pf'); } + +export function serializeUniversalEventPropertyFilters(filters: EventPropertyFilter[]) { + return Object.fromEntries( + filters.map((filter, index) => [ + `epf${index}`, + `${filter.dataType}.${filter.operator}.${encodePropertyName(filter.propertyName)}.${filter.value}`, + ]), + ); +} + +export function serializeSessionPropertyFilters(filters: SessionPropertyFilter[]) { + return Object.fromEntries( + filters.map((filter, index) => [ + `spf${index}`, + `${filter.dataType}.${filter.operator}.${encodePropertyName(filter.propertyName)}.${filter.value}`, + ]), + ); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 1d5fe46b0..18b53a1cb 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -309,6 +309,14 @@ function parseFilters(filters: Record, options?: QueryOptions) { const cohortFilters = Object.fromEntries( Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), ); + const { + sql: eventPropertyFilterQuery, + params: eventPropertyFilterParams, + } = getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone); + const { + sql: sessionPropertyFilterQuery, + params: sessionPropertyFilterParams, + } = getSessionPropertyFilterQuery((filters as QueryFilters).sessionPropertyFilters, filters.timezone); return { joinSessionQuery: @@ -316,8 +324,14 @@ function parseFilters(filters: Record, options?: QueryOptions) { ? `inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id` : '', dateQuery: getDateQuery(filters), - filterQuery: getFilterQuery(filters, options), - queryParams: getQueryParams(filters), + filterQuery: [getFilterQuery(filters, options), eventPropertyFilterQuery, sessionPropertyFilterQuery] + .filter(Boolean) + .join('\n'), + queryParams: { + ...getQueryParams(filters), + ...eventPropertyFilterParams, + ...sessionPropertyFilterParams, + }, cohortQuery: getCohortQuery(cohortFilters), excludeBounceQuery: getExcludeBounceQuery(filters), }; @@ -450,6 +464,258 @@ function getPropertyFilterQuery( return { sql: parts.join('\n'), params }; } +function getEventPropertyFilterQuery( + filters: PropertyFilter[] = [], + timezone?: string, +): { + sql: string; + params: Record; +} { + if (!filters.length) { + return { sql: '', params: {} }; + } + + const clauses: string[] = []; + const keyRefs: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, index) => { + const keyParam = `epf_key_${index}`; + const valParam = `epf_val_${index}`; + params[keyParam] = propertyName; + keyRefs.push(`{{${keyParam}}}`); + + let condition: string; + + switch (dataType) { + case DATA_TYPE.number: { + const col = 'cast(number_value as decimal)'; + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + [OPERATORS.equals]: `${col} = {{${valParam}}}`, + [OPERATORS.notEquals]: `${col} != {{${valParam}}}`, + [OPERATORS.greaterThan]: `${col} > {{${valParam}}}`, + [OPERATORS.lessThan]: `${col} < {{${valParam}}}`, + [OPERATORS.greaterThanEquals]: `${col} >= {{${valParam}}}`, + [OPERATORS.lessThanEquals]: `${col} <= {{${valParam}}}`, + }; + condition = opMap[operator] ?? `${col} = {{${valParam}}}`; + break; + } + case DATA_TYPE.date: { + if (!value) return; + params[valParam] = value; + const dateCol = + timezone && !isUtcTimezone(timezone) + ? `(date_value at time zone {{timezone}})::date` + : `(date_value at time zone 'utc')::date`; + const opMap: Record = { + [OPERATORS.before]: `${dateCol} < {{${valParam}::date}}`, + [OPERATORS.after]: `${dateCol} > {{${valParam}::date}}`, + }; + condition = opMap[operator] ?? `${dateCol} = {{${valParam}::date}}`; + break; + } + case DATA_TYPE.array: { + if (!value) return; + params[valParam] = value; + condition = + operator === OPERATORS.contains + ? `exists ( + select 1 + from jsonb_array_elements_text(coalesce(string_value, '[]')::jsonb) as array_item(value) + where array_item.value = {{${valParam}}} + )` + : `not exists ( + select 1 + from jsonb_array_elements_text(coalesce(string_value, '[]')::jsonb) as array_item(value) + where array_item.value = {{${valParam}}} + )`; + break; + } + default: { + const col = 'string_value'; + + if (EQUALITY_OPERATORS.includes(operator)) { + const vals = value.split(',').filter(Boolean); + + if (!vals.length) return; + + params[valParam] = vals; + condition = + operator === OPERATORS.equals + ? `${col} = ANY({{${valParam}}})` + : `${col} != ALL({{${valParam}}})`; + } else if (REGEX_OPERATORS.includes(operator)) { + if (!value) return; + + params[valParam] = value; + condition = + operator === OPERATORS.regex + ? `${col} ~* {{${valParam}}}` + : `${col} !~* {{${valParam}}}`; + } else { + if (!value) return; + + params[valParam] = `%${value}%`; + condition = + operator === OPERATORS.contains + ? `${col} ilike {{${valParam}}}` + : `${col} not ilike {{${valParam}}}`; + } + break; + } + } + + clauses.push( + `bool_or(data_key = {{${keyParam}}} and data_type = ${dataType} and ${condition})`, + ); + }); + + if (!clauses.length) { + return { sql: '', params: {} }; + } + + return { + sql: `and website_event.event_id in ( + select website_event_id + from event_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and data_key in (${keyRefs.join(', ')}) + group by website_event_id + having ${clauses.join('\n and ')} + )`, + params, + }; +} + +function getSessionPropertyFilterQuery( + filters: PropertyFilter[] = [], + timezone?: string, +): { + sql: string; + params: Record; +} { + if (!filters.length) { + return { sql: '', params: {} }; + } + + const clauses: string[] = []; + const keyRefs: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, index) => { + const keyParam = `spf_key_${index}`; + const valParam = `spf_val_${index}`; + params[keyParam] = propertyName; + keyRefs.push(`{{${keyParam}}}`); + + let condition: string; + + switch (dataType) { + case DATA_TYPE.number: { + const col = 'cast(number_value as decimal)'; + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + [OPERATORS.equals]: `${col} = {{${valParam}}}`, + [OPERATORS.notEquals]: `${col} != {{${valParam}}}`, + [OPERATORS.greaterThan]: `${col} > {{${valParam}}}`, + [OPERATORS.lessThan]: `${col} < {{${valParam}}}`, + [OPERATORS.greaterThanEquals]: `${col} >= {{${valParam}}}`, + [OPERATORS.lessThanEquals]: `${col} <= {{${valParam}}}`, + }; + condition = opMap[operator] ?? `${col} = {{${valParam}}}`; + break; + } + case DATA_TYPE.date: { + if (!value) return; + params[valParam] = value; + const dateCol = + timezone && !isUtcTimezone(timezone) + ? `(date_value at time zone {{timezone}})::date` + : `(date_value at time zone 'utc')::date`; + const opMap: Record = { + [OPERATORS.before]: `${dateCol} < {{${valParam}::date}}`, + [OPERATORS.after]: `${dateCol} > {{${valParam}::date}}`, + }; + condition = opMap[operator] ?? `${dateCol} = {{${valParam}::date}}`; + break; + } + case DATA_TYPE.array: { + if (!value) return; + params[valParam] = value; + condition = + operator === OPERATORS.contains + ? `exists ( + select 1 + from jsonb_array_elements_text(coalesce(string_value, '[]')::jsonb) as array_item(value) + where array_item.value = {{${valParam}}} + )` + : `not exists ( + select 1 + from jsonb_array_elements_text(coalesce(string_value, '[]')::jsonb) as array_item(value) + where array_item.value = {{${valParam}}} + )`; + break; + } + default: { + const col = 'string_value'; + + if (EQUALITY_OPERATORS.includes(operator)) { + const vals = value.split(',').filter(Boolean); + + if (!vals.length) return; + + params[valParam] = vals; + condition = + operator === OPERATORS.equals + ? `${col} = ANY({{${valParam}}})` + : `${col} != ALL({{${valParam}}})`; + } else if (REGEX_OPERATORS.includes(operator)) { + if (!value) return; + + params[valParam] = value; + condition = + operator === OPERATORS.regex + ? `${col} ~* {{${valParam}}}` + : `${col} !~* {{${valParam}}}`; + } else { + if (!value) return; + + params[valParam] = `%${value}%`; + condition = + operator === OPERATORS.contains + ? `${col} ilike {{${valParam}}}` + : `${col} not ilike {{${valParam}}}`; + } + break; + } + } + + clauses.push( + `bool_or(data_key = {{${keyParam}}} and data_type = ${dataType} and ${condition})`, + ); + }); + + if (!clauses.length) { + return { sql: '', params: {} }; + } + + return { + sql: `and exists ( + select 1 + from session_data + where website_id = website_event.website_id + and session_id = website_event.session_id + and data_key in (${keyRefs.join(', ')}) + group by website_id, session_id + having ${clauses.join('\n and ')} + )`, + params, + }; +} + async function executeRawQuery( sql: string, data: Record, diff --git a/src/lib/request.ts b/src/lib/request.ts index ddf7b7614..e21a243d0 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -4,7 +4,11 @@ import { checkAuth } from '@/lib/auth'; import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; import { fetchAccount, fetchWebsite } from '@/lib/load'; -import { filtersArrayToObject } from '@/lib/params'; +import { + filtersArrayToObject, + parseSessionPropertyFilters, + parseUniversalEventPropertyFilters, +} from '@/lib/params'; import { badRequest, unauthorized } from '@/lib/response'; import type { QueryFilters } from '@/lib/types'; import { getWebsiteSegment } from '@/queries/prisma'; @@ -32,7 +36,10 @@ export async function parseRequest( // Re-add dynamic params stripped by Zod schema: suffixed filter params (browser1, os2) for (const key of Object.keys(rawQuery)) { - if ((/\d+$/.test(key) || /^pf_/.test(key)) && !(key in query)) { + if ( + (/\d+$/.test(key) || /^pf_/.test(key) || /^epf\d+$/.test(key) || /^spf\d+$/.test(key)) && + !(key in query) + ) { query[key] = rawQuery[key]; } } @@ -114,6 +121,8 @@ export async function getQueryFilters( ): Promise { const dateRange = getRequestDateRange(params); const filters = getRequestFilters(params); + const eventPropertyFilters = parseUniversalEventPropertyFilters(params); + const sessionPropertyFilters = parseSessionPropertyFilters(params); let match = params?.match; @@ -169,6 +178,8 @@ export async function getQueryFilters( ...filters, match, minDuration: params?.minDuration, + eventPropertyFilters, + sessionPropertyFilters, page: params?.page, pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined, orderBy: params?.orderBy, diff --git a/src/lib/types.ts b/src/lib/types.ts index 9544263bd..d069538da 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -48,6 +48,7 @@ export interface PropertyFilter { } export type EventPropertyFilter = PropertyFilter; +export type SessionPropertyFilter = PropertyFilter; export interface Filter { name: string; @@ -126,6 +127,8 @@ export interface QueryFilters SegmentParams { minDuration?: number; cohortFilters?: QueryFilters; + eventPropertyFilters?: EventPropertyFilter[]; + sessionPropertyFilters?: SessionPropertyFilter[]; } export interface DateParams { diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts index 70e134e5b..bc95a3bb1 100644 --- a/src/queries/sql/events/getEventDataValues.ts +++ b/src/queries/sql/events/getEventDataValues.ts @@ -14,7 +14,7 @@ interface WebsiteEventData { export async function getEventDataValues( ...args: [ websiteId: string, - eventName: string, + eventName: string | undefined, filters: QueryFilters & { propertyName?: string; dataType?: number }, ] ): Promise { @@ -26,7 +26,7 @@ export async function getEventDataValues( async function relationalQuery( websiteId: string, - eventName: string, + eventName: string | undefined, filters: QueryFilters & { propertyName?: string; dataType?: number }, ) { const { rawQuery, parseFilters, getDateSQL } = prisma; @@ -35,6 +35,7 @@ async function relationalQuery( ...filters, websiteId, }); + const eventNameFilter = eventName ? 'and website_event.event_name = {{eventName}}' : ''; if (dataType === DATA_TYPE.array) { return rawQuery( @@ -47,7 +48,7 @@ async function relationalQuery( and website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type = 2 - and website_event.event_name = {{eventName}} + ${eventNameFilter} cross join lateral jsonb_array_elements_text(coalesce(event_data.string_value, '[]')::jsonb) as array_item(value) ${cohortQuery} ${joinSessionQuery} @@ -79,7 +80,7 @@ async function relationalQuery( and website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type = 2 - and website_event.event_name = {{eventName}} + ${eventNameFilter} ${cohortQuery} ${joinSessionQuery} where event_data.website_id = {{websiteId::uuid}} @@ -98,12 +99,14 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - eventName: string, + eventName: string | undefined, filters: QueryFilters & { propertyName?: string; dataType?: number }, ): Promise<{ value: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; const { dataType } = filters; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const eventNameFilter = eventName ? 'and event_name = {eventName:String}' : ''; + const eventDataNameFilter = eventName ? 'and event_data.event_name = {eventName:String}' : ''; if (dataType === DATA_TYPE.array) { return rawQuery( @@ -118,14 +121,14 @@ async function clickhouseQuery( where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = 2 - and event_name = {eventName:String}) website_event + ${eventNameFilter}) website_event on website_event.event_id = event_data.event_id and website_event.session_id = event_data.session_id and website_event.website_id = event_data.website_id ${cohortQuery} where event_data.website_id = {websiteId:UUID} and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_data.event_name = {eventName:String} + ${eventDataNameFilter} and event_data.data_key = {propertyName:String} and event_data.data_type = ${DATA_TYPE.array} ${filterQuery} @@ -152,14 +155,14 @@ async function clickhouseQuery( where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = 2 - and event_name = {eventName:String}) website_event + ${eventNameFilter}) website_event on website_event.event_id = event_data.event_id and website_event.session_id = event_data.session_id and website_event.website_id = event_data.website_id ${cohortQuery} where event_data.website_id = {websiteId:UUID} and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_data.event_name = {eventName:String} + ${eventDataNameFilter} and event_data.data_key = {propertyName:String} ${dataType ? `and event_data.data_type = ${dataType}` : ''} ${filterQuery}