|
|
|
@@ -1,7 +1,19 @@
|
|
|
|
|
import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
|
|
|
|
import {
|
|
|
|
|
Button,
|
|
|
|
|
Input,
|
|
|
|
|
Label,
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@plunk/ui';
|
|
|
|
|
import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types';
|
|
|
|
|
import {Plus, Trash2, GripVertical, Check, ChevronsUpDown, Search} from 'lucide-react';
|
|
|
|
|
import {useState, useEffect, useMemo, useCallback, memo} from 'react';
|
|
|
|
|
import {Check, ChevronsUpDown, GripVertical, Plus, Search, Trash2} from 'lucide-react';
|
|
|
|
|
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
|
|
|
|
|
import {network} from '../lib/network';
|
|
|
|
|
|
|
|
|
|
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
|
|
|
@@ -80,7 +92,10 @@ function useAvailableOptions() {
|
|
|
|
|
if (name.startsWith('email.')) {
|
|
|
|
|
emailOptions.push({
|
|
|
|
|
value: name,
|
|
|
|
|
label: name.replace('email.', '').replace(/([A-Z])/g, ' $1').trim(),
|
|
|
|
|
label: name
|
|
|
|
|
.replace('email.', '')
|
|
|
|
|
.replace(/([A-Z])/g, ' $1')
|
|
|
|
|
.trim(),
|
|
|
|
|
type: 'event' as const,
|
|
|
|
|
category: 'Email Activity' as const,
|
|
|
|
|
});
|
|
|
|
@@ -140,20 +155,28 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === 'boolean') {
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
|
|
|
|
|
);
|
|
|
|
|
return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'exists', 'notExists'].includes(op.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === 'number' || type === 'date') {
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
|
|
|
|
|
[
|
|
|
|
|
'equals',
|
|
|
|
|
'notEquals',
|
|
|
|
|
'greaterThan',
|
|
|
|
|
'lessThan',
|
|
|
|
|
'greaterThanOrEqual',
|
|
|
|
|
'lessThanOrEqual',
|
|
|
|
|
'exists',
|
|
|
|
|
'notExists',
|
|
|
|
|
'within',
|
|
|
|
|
].includes(op.value),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// String type - no within operator, no comparison operators
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
|
|
|
|
|
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value),
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
@@ -161,7 +184,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
|
|
|
|
|
|
|
|
|
|
// Get field type from available fields
|
|
|
|
|
const fieldOption = useMemo(() => availableFields.find(f => f.value === filter.field), [availableFields, filter.field]);
|
|
|
|
|
const fieldOption = useMemo(
|
|
|
|
|
() => availableFields.find(f => f.value === filter.field),
|
|
|
|
|
[availableFields, filter.field],
|
|
|
|
|
);
|
|
|
|
|
const fieldType = fieldOption?.type || 'string';
|
|
|
|
|
|
|
|
|
|
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
|
|
|
|
@@ -174,24 +200,33 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
|
|
|
|
|
// Filter operators based on field type
|
|
|
|
|
if (fieldType === 'boolean') {
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
|
|
|
|
|
);
|
|
|
|
|
return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'exists', 'notExists'].includes(op.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fieldType === 'number' || fieldType === 'date') {
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
|
|
|
|
|
[
|
|
|
|
|
'equals',
|
|
|
|
|
'notEquals',
|
|
|
|
|
'greaterThan',
|
|
|
|
|
'lessThan',
|
|
|
|
|
'greaterThanOrEqual',
|
|
|
|
|
'lessThanOrEqual',
|
|
|
|
|
'exists',
|
|
|
|
|
'notExists',
|
|
|
|
|
'within',
|
|
|
|
|
].includes(op.value),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// String type - no within operator, no comparison operators
|
|
|
|
|
return STANDARD_OPERATORS.filter(op =>
|
|
|
|
|
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
|
|
|
|
|
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value),
|
|
|
|
|
);
|
|
|
|
|
}, [fieldType, isEventOrEmailActivity]);
|
|
|
|
|
|
|
|
|
|
const handleFieldChange = useCallback((value: string) => {
|
|
|
|
|
const handleFieldChange = useCallback(
|
|
|
|
|
(value: string) => {
|
|
|
|
|
const selectedField = availableFields.find(f => f.value === value);
|
|
|
|
|
const newFieldType = selectedField?.type || 'string';
|
|
|
|
|
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
|
|
|
@@ -246,7 +281,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setSearch('');
|
|
|
|
|
}, [availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType]);
|
|
|
|
|
},
|
|
|
|
|
[availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Get label for selected field
|
|
|
|
|
const getFieldLabel = useCallback(() => {
|
|
|
|
@@ -267,16 +304,20 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
|
|
|
|
|
// Filter fields based on search (memoized to avoid expensive filter on every keystroke)
|
|
|
|
|
const filteredGroups = useMemo(() => {
|
|
|
|
|
return Object.entries(groupedFields).reduce((acc, [category, fields]) => {
|
|
|
|
|
const filtered = fields.filter(f =>
|
|
|
|
|
return Object.entries(groupedFields).reduce(
|
|
|
|
|
(acc, [category, fields]) => {
|
|
|
|
|
const filtered = fields.filter(
|
|
|
|
|
f =>
|
|
|
|
|
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
|
|
|
|
f.value.toLowerCase().includes(search.toLowerCase())
|
|
|
|
|
f.value.toLowerCase().includes(search.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
if (filtered.length > 0) {
|
|
|
|
|
acc[category] = filtered;
|
|
|
|
|
}
|
|
|
|
|
return acc;
|
|
|
|
|
}, {} as Record<string, FieldOption[]>);
|
|
|
|
|
},
|
|
|
|
|
{} as Record<string, FieldOption[]>,
|
|
|
|
|
);
|
|
|
|
|
}, [groupedFields, search]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
@@ -303,21 +344,17 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search fields, events, or email activity..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="max-h-[300px] overflow-y-auto p-1">
|
|
|
|
|
{Object.keys(filteredGroups).length === 0 ? (
|
|
|
|
|
<div className="py-6 text-center text-sm text-neutral-500">
|
|
|
|
|
No fields or events found.
|
|
|
|
|
</div>
|
|
|
|
|
<div className="py-6 text-center text-sm text-neutral-500">No fields or events found.</div>
|
|
|
|
|
) : (
|
|
|
|
|
Object.entries(filteredGroups).map(([category, fields]) => (
|
|
|
|
|
<div key={category} className="py-1">
|
|
|
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">
|
|
|
|
|
{category}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
|
|
|
|
|
{fields.map(field => (
|
|
|
|
|
<button
|
|
|
|
|
key={field.value}
|
|
|
|
@@ -325,9 +362,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
className="w-full flex items-center rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-100 cursor-pointer text-left"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={`mr-2 h-4 w-4 ${
|
|
|
|
|
filter.field === field.value ? 'opacity-100' : 'opacity-0'
|
|
|
|
|
}`}
|
|
|
|
|
className={`mr-2 h-4 w-4 ${filter.field === field.value ? 'opacity-100' : 'opacity-0'}`}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
@@ -420,7 +455,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
className="text-sm flex-1"
|
|
|
|
|
min="1"
|
|
|
|
|
/>
|
|
|
|
|
<Select value={filter.unit || 'days'} onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}>
|
|
|
|
|
<Select
|
|
|
|
|
value={filter.unit || 'days'}
|
|
|
|
|
onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="text-sm w-[110px]">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
@@ -434,7 +472,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
) : fieldType === 'boolean' ? (
|
|
|
|
|
<Select value={String(filter.value ?? 'true')} onValueChange={v => onChange({...filter, value: v === 'true'})}>
|
|
|
|
|
<Select
|
|
|
|
|
value={String(filter.value ?? 'true')}
|
|
|
|
|
onValueChange={v => onChange({...filter, value: v === 'true'})}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="text-sm">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
@@ -473,7 +514,13 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onRemove}
|
|
|
|
|
className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
@@ -496,19 +543,25 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|
|
|
|
});
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
|
|
|
|
|
const updateFilter = useCallback((index: number, filter: SegmentFilter) => {
|
|
|
|
|
const updateFilter = useCallback(
|
|
|
|
|
(index: number, filter: SegmentFilter) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...group,
|
|
|
|
|
filters: group.filters.map((f, i) => (i === index ? filter : f)),
|
|
|
|
|
});
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
},
|
|
|
|
|
[group, onChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const removeFilter = useCallback((index: number) => {
|
|
|
|
|
const removeFilter = useCallback(
|
|
|
|
|
(index: number) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...group,
|
|
|
|
|
filters: group.filters.filter((_, i) => i !== index),
|
|
|
|
|
});
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
},
|
|
|
|
|
[group, onChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const addNestedCondition = useCallback(() => {
|
|
|
|
|
onChange({
|
|
|
|
@@ -520,15 +573,19 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|
|
|
|
});
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
|
|
|
|
|
const updateNestedCondition = useCallback((condition: FilterCondition) => {
|
|
|
|
|
const updateNestedCondition = useCallback(
|
|
|
|
|
(condition: FilterCondition) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...group,
|
|
|
|
|
conditions: condition,
|
|
|
|
|
});
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
},
|
|
|
|
|
[group, onChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const removeNestedCondition = useCallback(() => {
|
|
|
|
|
const {conditions, ...rest} = group;
|
|
|
|
|
const rest = {...group};
|
|
|
|
|
delete rest.conditions;
|
|
|
|
|
onChange(rest);
|
|
|
|
|
}, [group, onChange]);
|
|
|
|
|
|
|
|
|
@@ -536,14 +593,22 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|
|
|
|
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}>
|
|
|
|
|
<div
|
|
|
|
|
className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<GripVertical className="h-4 w-4 text-neutral-400" />
|
|
|
|
|
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{onRemove && (
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onRemove}
|
|
|
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
@@ -551,7 +616,13 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{group.filters.map((filter, index) => (
|
|
|
|
|
<FilterRow key={`${filter.field}-${filter.operator}-${index}`} filter={filter} onChange={f => updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} />
|
|
|
|
|
<FilterRow
|
|
|
|
|
key={`${filter.field}-${filter.operator}-${index}`}
|
|
|
|
|
filter={filter}
|
|
|
|
|
onChange={f => updateFilter(index, f)}
|
|
|
|
|
onRemove={() => removeFilter(index)}
|
|
|
|
|
availableFields={availableFields}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{group.conditions && (
|
|
|
|
@@ -568,7 +639,12 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|
|
|
|
Remove nested
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<FilterConditionComponent condition={group.conditions} onChange={updateNestedCondition} depth={depth + 1} availableFields={availableFields} />
|
|
|
|
|
<FilterConditionComponent
|
|
|
|
|
condition={group.conditions}
|
|
|
|
|
onChange={updateNestedCondition}
|
|
|
|
|
depth={depth + 1}
|
|
|
|
|
availableFields={availableFields}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
@@ -604,19 +680,25 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
|
|
|
|
|
});
|
|
|
|
|
}, [condition, onChange]);
|
|
|
|
|
|
|
|
|
|
const updateGroup = useCallback((index: number, group: FilterGroup) => {
|
|
|
|
|
const updateGroup = useCallback(
|
|
|
|
|
(index: number, group: FilterGroup) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...condition,
|
|
|
|
|
groups: condition.groups.map((g, i) => (i === index ? group : g)),
|
|
|
|
|
});
|
|
|
|
|
}, [condition, onChange]);
|
|
|
|
|
},
|
|
|
|
|
[condition, onChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const removeGroup = useCallback((index: number) => {
|
|
|
|
|
const removeGroup = useCallback(
|
|
|
|
|
(index: number) => {
|
|
|
|
|
onChange({
|
|
|
|
|
...condition,
|
|
|
|
|
groups: condition.groups.filter((_, i) => i !== index),
|
|
|
|
|
});
|
|
|
|
|
}, [condition, onChange]);
|
|
|
|
|
},
|
|
|
|
|
[condition, onChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const toggleLogic = useCallback(() => {
|
|
|
|
|
onChange({
|
|
|
|
@@ -647,7 +729,9 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
|
|
|
|
|
<div key={`group-${depth}-${index}-${group.filters.length}`}>
|
|
|
|
|
{index > 0 && (
|
|
|
|
|
<div className="flex items-center justify-center my-2">
|
|
|
|
|
<div className="px-3 py-1 bg-neutral-900 text-white text-xs font-bold font-mono rounded-full">{condition.logic}</div>
|
|
|
|
|
<div className="px-3 py-1 bg-neutral-900 text-white text-xs font-bold font-mono rounded-full">
|
|
|
|
|
{condition.logic}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<FilterGroupComponent
|
|
|
|
|