Fix lint errors
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -125,156 +124,154 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
setShowUserMenu(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const handleLogoutClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleLogout();
|
||||
}, [handleLogout]);
|
||||
const handleLogoutClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleLogout();
|
||||
},
|
||||
[handleLogout],
|
||||
);
|
||||
|
||||
// Sidebar content (reusable for both desktop and mobile)
|
||||
const SidebarContent = () => (
|
||||
const sidebarContent = (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||
</div>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||
</div>
|
||||
|
||||
{/* Project Switcher */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative" ref={projectMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleProjectMenu}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
||||
</div>
|
||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Project Dropdown */}
|
||||
{showProjectMenu && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
{availableProjects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900">{project.name}</span>
|
||||
{activeProject?.id === project.id && (
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-neutral-200 my-1" />
|
||||
<Link
|
||||
href="/projects/create"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
|
||||
{section.title && (
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map(item => {
|
||||
const isActive = router.pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Settings & User Menu */}
|
||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
{/* Project Switcher */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative" ref={projectMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleProjectMenu}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleUserMenu}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
<span className="flex-1 text-left truncate">{user?.email}</span>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Project Dropdown */}
|
||||
{showProjectMenu && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
{availableProjects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900">{project.name}</span>
|
||||
{activeProject?.id === project.id && (
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-neutral-200 my-1" />
|
||||
<Link
|
||||
href="/projects/create"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
|
||||
{section.title && (
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map(item => {
|
||||
const isActive = router.pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Settings & User Menu */}
|
||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleUserMenu}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
<span className="flex-1 text-left truncate">{user?.email}</span>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-50">
|
||||
{/* Desktop Sidebar - Hidden on mobile */}
|
||||
<div className="hidden lg:flex w-64 bg-white border-r border-neutral-200 flex-col">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
<div className="hidden lg:flex w-64 bg-white border-r border-neutral-200 flex-col">{sidebarContent}</div>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{showMobileMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
>
|
||||
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setShowMobileMenu(false)}>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
</div>
|
||||
)}
|
||||
@@ -285,9 +282,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
showMobileMenu ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
<div className="flex flex-col h-full">{sidebarContent}</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
|
||||
@@ -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,79 +200,90 @@ 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 selectedField = availableFields.find(f => f.value === value);
|
||||
const newFieldType = selectedField?.type || 'string';
|
||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
||||
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
||||
const handleFieldChange = useCallback(
|
||||
(value: string) => {
|
||||
const selectedField = availableFields.find(f => f.value === value);
|
||||
const newFieldType = selectedField?.type || 'string';
|
||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
||||
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
||||
|
||||
// Determine default operator and value based on new field type
|
||||
let newOperator = filter.operator;
|
||||
let newValue: string | number | boolean | undefined = undefined;
|
||||
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
|
||||
// Determine default operator and value based on new field type
|
||||
let newOperator = filter.operator;
|
||||
let newValue: string | number | boolean | undefined = undefined;
|
||||
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
|
||||
|
||||
if (isEvent && !currentOperatorIsEvent) {
|
||||
// Switching to event field
|
||||
newOperator = 'triggered';
|
||||
newValue = undefined;
|
||||
newUnit = undefined;
|
||||
} else if (!isEvent && currentOperatorIsEvent) {
|
||||
// Switching from event to non-event field
|
||||
newOperator = 'equals';
|
||||
newValue = getDefaultValueForType(newFieldType);
|
||||
newUnit = undefined;
|
||||
} else if (fieldType !== newFieldType) {
|
||||
// Field type changed (e.g., date to boolean, number to string)
|
||||
// Check if current operator is valid for new type
|
||||
const validOperators = getOperatorsForType(newFieldType, isEvent);
|
||||
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
|
||||
|
||||
if (!isOperatorValid) {
|
||||
if (isEvent && !currentOperatorIsEvent) {
|
||||
// Switching to event field
|
||||
newOperator = 'triggered';
|
||||
newValue = undefined;
|
||||
newUnit = undefined;
|
||||
} else if (!isEvent && currentOperatorIsEvent) {
|
||||
// Switching from event to non-event field
|
||||
newOperator = 'equals';
|
||||
}
|
||||
|
||||
// Reset value to appropriate default for new type
|
||||
newValue = getDefaultValueForType(newFieldType);
|
||||
|
||||
// Always clear unit when changing field types, even if operator is still valid
|
||||
// This handles cases like switching from date "within" to string field
|
||||
newUnit = undefined;
|
||||
|
||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
||||
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
||||
if (!newOperatorNeedsUnit) {
|
||||
// Convert numeric value back to appropriate type for the field
|
||||
newValue = getDefaultValueForType(newFieldType);
|
||||
newUnit = undefined;
|
||||
} else if (fieldType !== newFieldType) {
|
||||
// Field type changed (e.g., date to boolean, number to string)
|
||||
// Check if current operator is valid for new type
|
||||
const validOperators = getOperatorsForType(newFieldType, isEvent);
|
||||
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
|
||||
|
||||
if (!isOperatorValid) {
|
||||
newOperator = 'equals';
|
||||
}
|
||||
|
||||
// Reset value to appropriate default for new type
|
||||
newValue = getDefaultValueForType(newFieldType);
|
||||
|
||||
// Always clear unit when changing field types, even if operator is still valid
|
||||
// This handles cases like switching from date "within" to string field
|
||||
newUnit = undefined;
|
||||
|
||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
||||
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
||||
if (!newOperatorNeedsUnit) {
|
||||
// Convert numeric value back to appropriate type for the field
|
||||
newValue = getDefaultValueForType(newFieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
field: value,
|
||||
operator: newOperator,
|
||||
value: newValue,
|
||||
unit: newUnit,
|
||||
});
|
||||
onChange({
|
||||
field: value,
|
||||
operator: newOperator,
|
||||
value: newValue,
|
||||
unit: newUnit,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}, [availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType]);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
},
|
||||
[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 =>
|
||||
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
f.value.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
acc[category] = filtered;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, FieldOption[]>);
|
||||
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()),
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
acc[category] = filtered;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} 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) => {
|
||||
onChange({
|
||||
...group,
|
||||
filters: group.filters.map((f, i) => (i === index ? filter : f)),
|
||||
});
|
||||
}, [group, onChange]);
|
||||
const updateFilter = useCallback(
|
||||
(index: number, filter: SegmentFilter) => {
|
||||
onChange({
|
||||
...group,
|
||||
filters: group.filters.map((f, i) => (i === index ? filter : f)),
|
||||
});
|
||||
},
|
||||
[group, onChange],
|
||||
);
|
||||
|
||||
const removeFilter = useCallback((index: number) => {
|
||||
onChange({
|
||||
...group,
|
||||
filters: group.filters.filter((_, i) => i !== index),
|
||||
});
|
||||
}, [group, onChange]);
|
||||
const removeFilter = useCallback(
|
||||
(index: number) => {
|
||||
onChange({
|
||||
...group,
|
||||
filters: group.filters.filter((_, i) => i !== index),
|
||||
});
|
||||
},
|
||||
[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) => {
|
||||
onChange({
|
||||
...group,
|
||||
conditions: condition,
|
||||
});
|
||||
}, [group, onChange]);
|
||||
const updateNestedCondition = useCallback(
|
||||
(condition: FilterCondition) => {
|
||||
onChange({
|
||||
...group,
|
||||
conditions: condition,
|
||||
});
|
||||
},
|
||||
[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) => {
|
||||
onChange({
|
||||
...condition,
|
||||
groups: condition.groups.map((g, i) => (i === index ? group : g)),
|
||||
});
|
||||
}, [condition, onChange]);
|
||||
const updateGroup = useCallback(
|
||||
(index: number, group: FilterGroup) => {
|
||||
onChange({
|
||||
...condition,
|
||||
groups: condition.groups.map((g, i) => (i === index ? group : g)),
|
||||
});
|
||||
},
|
||||
[condition, onChange],
|
||||
);
|
||||
|
||||
const removeGroup = useCallback((index: number) => {
|
||||
onChange({
|
||||
...condition,
|
||||
groups: condition.groups.filter((_, i) => i !== index),
|
||||
});
|
||||
}, [condition, onChange]);
|
||||
const removeGroup = useCallback(
|
||||
(index: number) => {
|
||||
onChange({
|
||||
...condition,
|
||||
groups: condition.groups.filter((_, i) => i !== index),
|
||||
});
|
||||
},
|
||||
[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
|
||||
|
||||
@@ -8,28 +8,11 @@ import {
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Contact, Segment} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Database,
|
||||
Filter,
|
||||
MailCheck,
|
||||
MailX,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -86,10 +69,12 @@ export default function SegmentDetailPage() {
|
||||
setName(segment.name);
|
||||
setDescription(segment.description || '');
|
||||
setTrackMembership(segment.trackMembership);
|
||||
setCondition((segment.condition as unknown as FilterCondition) || {
|
||||
logic: 'AND',
|
||||
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
|
||||
});
|
||||
setCondition(
|
||||
(segment.condition as unknown as FilterCondition) || {
|
||||
logic: 'AND',
|
||||
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [segment]);
|
||||
|
||||
@@ -144,7 +129,6 @@ export default function SegmentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
|
||||
@@ -54,7 +54,7 @@ import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||
import {ReactFlowProvider} from '@xyflow/react';
|
||||
import {ContactSchemas, WorkflowSchemas} from '@plunk/shared';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
|
||||
interface WorkflowWithDetails extends Workflow {
|
||||
steps: (WorkflowStep & {
|
||||
@@ -411,7 +411,9 @@ export default function WorkflowEditorPage() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{workflow.description && <p className="text-neutral-500 mt-1 text-sm sm:text-base">{workflow.description}</p>}
|
||||
{workflow.description && (
|
||||
<p className="text-neutral-500 mt-1 text-sm sm:text-base">{workflow.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user