diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx index 950e914..df81ad7 100644 --- a/apps/web/src/components/DashboardLayout.tsx +++ b/apps/web/src/components/DashboardLayout.tsx @@ -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 */} -
- Plunk -

Plunk

-
+ {/* Logo */} +
+ Plunk +

Plunk

+
- {/* Project Switcher */} -
-
- - - {/* Project Dropdown */} - {showProjectMenu && ( -
- {availableProjects.map(project => ( - - ))} -
- - - Create project - -
- )} -
-
- - {/* Navigation */} - - - {/* Settings & User Menu */} -
- 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 */} +
+
+ - - {/* User Dropdown */} - {showUserMenu && ( -
- +
+
+ {activeProject?.name.charAt(0).toUpperCase() || 'P'}
- )} -
+ {activeProject?.name || 'Select project'} +
+ + + + {/* Project Dropdown */} + {showProjectMenu && ( +
+ {availableProjects.map(project => ( + + ))} +
+ + + Create project + +
+ )}
- - ); +
+ + {/* Navigation */} + + + {/* Settings & User Menu */} +
+ 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 + + +
+ + + {/* User Dropdown */} + {showUserMenu && ( +
+ +
+ )} +
+
+ + ); return (
{/* Desktop Sidebar - Hidden on mobile */} -
- -
+
{sidebarContent}
{/* Mobile Sidebar Overlay */} {showMobileMenu && ( -
setShowMobileMenu(false)} - > +
setShowMobileMenu(false)}>
)} @@ -285,9 +282,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) { showMobileMenu ? 'translate-x-0' : '-translate-x-full' }`} > -
- -
+
{sidebarContent}
{/* Main Content */} diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index 8dded84..1810185 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -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); + 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, + ); }, [groupedFields, search]); return ( @@ -303,21 +344,17 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available setSearch(e.target.value)} + onChange={e => setSearch(e.target.value)} className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0" />
{Object.keys(filteredGroups).length === 0 ? ( -
- No fields or events found. -
+
No fields or events found.
) : ( Object.entries(filteredGroups).map(([category, fields]) => (
-
- {category} -
+
{category}
{fields.map(field => (
-
@@ -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 ( -
+
Filter Group {depth > 0 && `(Nested)`}
{onRemove && ( - )} @@ -551,7 +616,13 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
{group.filters.map((filter, index) => ( - updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} /> + updateFilter(index, f)} + onRemove={() => removeFilter(index)} + availableFields={availableFields} + /> ))} {group.conditions && ( @@ -568,7 +639,12 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi Remove nested
- +
)} @@ -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
{index > 0 && (
-
{condition.logic}
+
+ {condition.logic} +
)} diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 1d08c37..f44a316 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -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() { )}
- {workflow.description &&

{workflow.description}

} + {workflow.description && ( +

{workflow.description}

+ )}