11import { ColorSquare } from '@/components/color-square' ;
22import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect' ;
3+ import { FilterTypeSelect } from '@/components/report/sidebar/filters/FilterTypeSelect' ;
34import { RenderDots } from '@/components/ui/RenderDots' ;
45import { Button } from '@/components/ui/button' ;
56import { Combobox } from '@/components/ui/combobox' ;
@@ -10,16 +11,43 @@ import { useAppParams } from '@/hooks/use-app-params';
1011import { useEventNames } from '@/hooks/use-event-names' ;
1112import { usePropertyValues } from '@/hooks/use-property-values' ;
1213import { useDispatch } from '@/redux' ;
14+ import { getOperatorsForType } from '@openpanel/constants' ;
1315import type {
1416 IChartEvent ,
1517 IChartEventFilter ,
1618 IChartEventFilterOperator ,
1719 IChartEventFilterValue ,
20+ IChartFilterValueType ,
1821} from '@openpanel/validation' ;
1922
2023import { SlidersHorizontal , Trash } from 'lucide-react' ;
2124import { changeEvent } from '../../reportSlice' ;
2225
26+ // Client-side sanity check: can this raw value possibly match the chosen cast
27+ // type? Mirrors the SQL casts in packages/db filter-cast.ts. Returns an error
28+ // message to show inline, or undefined when valid (or empty / untyped).
29+ function validateFilterValue (
30+ value : string ,
31+ type : IChartFilterValueType | undefined ,
32+ ) : string | undefined {
33+ if ( ! value ) {
34+ return undefined ;
35+ }
36+ switch ( type ) {
37+ case 'number' :
38+ return Number . isFinite ( Number ( value ) ) ? undefined : 'Not a valid number' ;
39+ case 'date' :
40+ case 'datetime' :
41+ return Number . isNaN ( Date . parse ( value ) ) ? 'Not a valid date' : undefined ;
42+ case 'boolean' :
43+ return value === 'true' || value === 'false'
44+ ? undefined
45+ : 'Use "true" or "false"' ;
46+ default :
47+ return undefined ;
48+ }
49+ }
50+
2351interface FilterProps {
2452 event : IChartEvent ;
2553 filter : IChartEventFilter ;
@@ -37,6 +65,12 @@ interface PureFilterProps {
3765 operator : IChartEventFilterOperator ,
3866 filter : IChartEventFilter ,
3967 ) => void ;
68+ // Optional: surfaces the cast-type select. Callers that don't pass it (the
69+ // overview/table/cohort modals) simply don't render the control.
70+ onChangeType ?: (
71+ type : IChartFilterValueType ,
72+ filter : IChartEventFilter ,
73+ ) => void ;
4074 className ?: string ;
4175 immediateInput ?: boolean ;
4276}
@@ -97,6 +131,45 @@ export function FilterItem({ filter, event }: FilterProps) {
97131 ) ;
98132 } ;
99133
134+ const onChangeType = (
135+ type : IChartFilterValueType ,
136+ { id } : IChartEventFilter ,
137+ ) => {
138+ dispatch (
139+ changeEvent ( {
140+ ...event ,
141+ type : 'event' ,
142+ filters : event . filters . map ( ( item ) => {
143+ if ( item . id !== id ) {
144+ return item ;
145+ }
146+
147+ // Keep the current operator if it's still valid for the new type,
148+ // otherwise fall back to the first allowed one (and trim the value
149+ // like onChangeOperator does, since the input shape may change).
150+ const allowed = getOperatorsForType ( type ) ;
151+ const operator = ( allowed as readonly string [ ] ) . includes (
152+ item . operator ,
153+ )
154+ ? item . operator
155+ : allowed [ 0 ] ! ;
156+
157+ return {
158+ ...item ,
159+ type,
160+ operator,
161+ value :
162+ operator === item . operator
163+ ? item . value
164+ : item . value
165+ ? item . value . filter ( Boolean ) . slice ( 0 , 1 )
166+ : [ ] ,
167+ } ;
168+ } ) ,
169+ } ) ,
170+ ) ;
171+ } ;
172+
100173 const dispatch = useDispatch ( ) ;
101174 return (
102175 < PureFilterItem
@@ -105,6 +178,7 @@ export function FilterItem({ filter, event }: FilterProps) {
105178 onRemove = { onRemove }
106179 onChangeValue = { onChangeValue }
107180 onChangeOperator = { onChangeOperator }
181+ onChangeType = { onChangeType }
108182 className = "px-4 py-2 shadow-[inset_6px_0_0] shadow-def-300 first:border-t"
109183 />
110184 ) ;
@@ -121,13 +195,31 @@ export function PureFilterItem({
121195 onRemove,
122196 onChangeValue,
123197 onChangeOperator,
198+ onChangeType,
124199 className,
125200 immediateInput,
126201} : PureFilterProps ) {
127202 const { projectId } = useAppParams ( ) ;
128203
129204 const isBooleanSessionFilter = filter . name === 'session.is_bounce' ;
130205 const isPerformedEventFilter = filter . name === 'session.performed_event' ;
206+ // The session.* filters above have a fixed shape (yes/no, event select), so
207+ // the cast-type control doesn't apply to them. Also requires the caller to
208+ // opt in by passing onChangeType.
209+ const showTypeSelect =
210+ ! ! onChangeType && ! isBooleanSessionFilter && ! isPerformedEventFilter ;
211+ // Only the free-text input path (gt/gte/lt/lte) carries a single typed value
212+ // we can validate inline; is/isNot use a multi-value combobox.
213+ const usesTypedInput =
214+ showTypeSelect &&
215+ filter . operator !== 'is' &&
216+ filter . operator !== 'isNot' ;
217+ const valueError = usesTypedInput
218+ ? validateFilterValue (
219+ filter . value [ 0 ] != null ? String ( filter . value [ 0 ] ) : '' ,
220+ filter . type ,
221+ )
222+ : undefined ;
131223
132224 const potentialValues = usePropertyValues ( {
133225 event : eventName ,
@@ -163,6 +255,10 @@ export function PureFilterItem({
163255 onChangeOperator ( operator , filter ) ;
164256 } ;
165257
258+ const changeFilterType = ( type : IChartFilterValueType ) => {
259+ onChangeType ?.( type , filter ) ;
260+ } ;
261+
166262 const renderValueControl = ( ) => {
167263 if ( isBooleanSessionFilter ) {
168264 return (
@@ -208,6 +304,7 @@ export function PureFilterItem({
208304 value = { filter . value [ 0 ] ? String ( filter . value [ 0 ] ) : '' }
209305 onChangeValue = { ( value ) => changeFilterValue ( [ value ] ) }
210306 immediate = { immediateInput }
307+ error = { valueError }
211308 />
212309 ) ;
213310 } ;
@@ -226,12 +323,22 @@ export function PureFilterItem({
226323 </ Button >
227324 </ div >
228325 < div className = "flex gap-1" >
326+ { showTypeSelect && (
327+ < FilterTypeSelect
328+ value = { filter . type }
329+ onChange = { changeFilterType }
330+ />
331+ ) }
229332 < FilterOperatorSelect
230333 value = { filter . operator }
231334 onChange = { changeFilterOperator }
335+ type = { filter . type }
232336 />
233337 { renderValueControl ( ) }
234338 </ div >
339+ { valueError && (
340+ < p className = "mt-1 text-destructive text-xs" > { valueError } </ p >
341+ ) }
235342 </ div >
236343 ) ;
237344}
0 commit comments