Skip to content

Commit 577a316

Browse files
committed
feat: cast filter values
1 parent 3139211 commit 577a316

12 files changed

Lines changed: 474 additions & 15 deletions

File tree

apps/start/src/components/report/sidebar/ReportSeriesItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export function ReportSeriesItem({
101101
name: action.value,
102102
operator: 'is',
103103
value: [],
104+
type: 'string',
104105
},
105106
],
106107
}),

apps/start/src/components/report/sidebar/filters/FilterItem.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ColorSquare } from '@/components/color-square';
22
import { FilterOperatorSelect } from '@/components/report/sidebar/filters/FilterOperatorSelect';
3+
import { FilterTypeSelect } from '@/components/report/sidebar/filters/FilterTypeSelect';
34
import { RenderDots } from '@/components/ui/RenderDots';
45
import { Button } from '@/components/ui/button';
56
import { Combobox } from '@/components/ui/combobox';
@@ -10,16 +11,43 @@ import { useAppParams } from '@/hooks/use-app-params';
1011
import { useEventNames } from '@/hooks/use-event-names';
1112
import { usePropertyValues } from '@/hooks/use-property-values';
1213
import { useDispatch } from '@/redux';
14+
import { getOperatorsForType } from '@openpanel/constants';
1315
import type {
1416
IChartEvent,
1517
IChartEventFilter,
1618
IChartEventFilterOperator,
1719
IChartEventFilterValue,
20+
IChartFilterValueType,
1821
} from '@openpanel/validation';
1922

2023
import { SlidersHorizontal, Trash } from 'lucide-react';
2124
import { 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+
2351
interface 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
}

apps/start/src/components/report/sidebar/filters/FilterOperatorSelect.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,47 @@
11
import { Button } from '@/components/ui/button';
22
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
3-
import { operators } from '@openpanel/constants';
4-
import type { IChartEventFilterOperator } from '@openpanel/validation';
5-
import { mapKeys } from '@openpanel/validation';
3+
import {
4+
getOperatorsForType,
5+
operators,
6+
operatorsShort,
7+
} from '@openpanel/constants';
8+
import type {
9+
IChartEventFilterOperator,
10+
IChartFilterValueType,
11+
} from '@openpanel/validation';
612

713
interface FilterOperatorSelectProps {
814
value: IChartEventFilterOperator;
915
onChange: (operator: IChartEventFilterOperator) => void;
16+
// The filter's declared value type. Constrains which operators are offered
17+
// (e.g. a Number can't `contains`, a Boolean only `is`/`isNot`).
18+
type?: IChartFilterValueType;
1019
children?: React.ReactNode;
1120
}
1221

1322
export function FilterOperatorSelect({
1423
value,
1524
onChange,
25+
type,
1626
children,
1727
}: FilterOperatorSelectProps) {
1828
const trigger = children ?? (
1929
<Button variant="outline" className="whitespace-nowrap">
20-
{operators[value]}
30+
{operatorsShort[value]}
2131
</Button>
2232
);
2333

2434
return (
2535
<DropdownMenuComposed
2636
onChange={onChange}
27-
items={mapKeys(operators)
28-
// Cohort operators are surfaced via CohortFilterItem, not here.
29-
.filter((key) => key !== 'inCohort' && key !== 'notInCohort')
30-
.map((key) => ({
31-
value: key,
32-
label: operators[key],
33-
}))}
37+
items={getOperatorsForType(type).map((key) => ({
38+
value: key,
39+
label: operatorsShort[key],
40+
// Only show the descriptive sub-line when it adds info beyond the
41+
// short label (i.e. the symbol operators).
42+
description:
43+
operatorsShort[key] === operators[key] ? undefined : operators[key],
44+
}))}
3445
label="Operator"
3546
>
3647
{trigger}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Button } from '@/components/ui/button';
2+
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
3+
import { filterValueTypes } from '@openpanel/constants';
4+
import type { IChartFilterValueType } from '@openpanel/validation';
5+
import { mapKeys } from '@openpanel/validation';
6+
7+
interface FilterTypeSelectProps {
8+
value: IChartFilterValueType | undefined;
9+
onChange: (type: IChartFilterValueType) => void;
10+
children?: React.ReactNode;
11+
}
12+
13+
// Cast type for the filter value/column. Drives which operators are available
14+
// (via getOperatorsForType) and how the value/column are cast in SQL. Defaults
15+
// to the "Text" label when unset (legacy filters).
16+
export function FilterTypeSelect({
17+
value,
18+
onChange,
19+
children,
20+
}: FilterTypeSelectProps) {
21+
const trigger = children ?? (
22+
<Button variant="outline" className="whitespace-nowrap">
23+
{filterValueTypes[value ?? 'string']}
24+
</Button>
25+
);
26+
27+
return (
28+
<DropdownMenuComposed
29+
onChange={onChange}
30+
items={mapKeys(filterValueTypes).map((key) => ({
31+
value: key,
32+
label: filterValueTypes[key],
33+
}))}
34+
label="Value type"
35+
>
36+
{trigger}
37+
</DropdownMenuComposed>
38+
);
39+
}

apps/start/src/components/ui/dropdown-menu.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ interface DropdownProps<Value> {
187187
items: {
188188
label: string;
189189
value: Value;
190+
// Optional secondary line rendered below the label in smaller muted text.
191+
description?: string;
190192
}[];
191193
onChange?: (value: Value) => void;
192194
}
@@ -216,7 +218,16 @@ export function DropdownMenuComposed<Value extends string>({
216218
onChange?.(item.value);
217219
}}
218220
>
219-
{item.label}
221+
{item.description ? (
222+
<div className="flex flex-col gap-0.5">
223+
<span>{item.label}</span>
224+
<span className="text-muted-foreground text-xs">
225+
{item.description}
226+
</span>
227+
</div>
228+
) : (
229+
item.label
230+
)}
220231
</DropdownMenuItem>
221232
))}
222233
</DropdownMenuGroup>

0 commit comments

Comments
 (0)