Refactor components to use memoization and callbacks for performance improvements

This commit is contained in:
Dries Augustyns
2025-12-04 10:14:18 +01:00
parent 3b73273629
commit 736672007b
7 changed files with 260 additions and 164 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from './ActivityFeed';
import {memo} from 'react';
import {
AlertCircle,
Calendar,
@@ -293,7 +294,7 @@ function getActivityConfig(activity: Activity): ActivityConfig {
}
}
export function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
const config = getActivityConfig(activity);
const Icon = config.icon;
const timestamp = new Date(activity.timestamp);
@@ -358,4 +359,4 @@ export function ActivityItem({activity, isUpcoming = false}: ActivityItemProps)
</div>
</div>
);
}
});
+17 -15
View File
@@ -2,6 +2,7 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '
import {AlertCircle, TrendingUp} from 'lucide-react';
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
import {useConfig} from '../lib/hooks/useConfig';
import {useCallback} from 'react';
interface BillingConsumptionProps {
projectId: string;
@@ -15,6 +16,22 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
// Always call the hook to satisfy Rules of Hooks
const {consumptionData, isLoading, error} = useBillingConsumption(projectId, hasSubscription && billingEnabled);
// Define callbacks BEFORE any conditional returns (Rules of Hooks)
const formatCurrency = useCallback((amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
}, []);
const formatDate = useCallback((dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}, []);
if (!billingEnabled) {
// If billing is globally disabled, hide the card entirely
return null;
@@ -79,21 +96,6 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
return null;
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return (
<Card>
<CardHeader>
+12 -12
View File
@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react';
import {memo, useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {BillingLimitSchemas} from '@plunk/shared';
@@ -270,24 +270,24 @@ interface UsageDisplayProps {
usage: CategoryLimit;
}
function UsageDisplay({category, usage}: UsageDisplayProps) {
const getStatusColor = () => {
const UsageDisplay = memo(function UsageDisplay({category, usage}: UsageDisplayProps) {
const statusColor = useMemo(() => {
if (usage.isBlocked) return 'text-red-600';
if (usage.isWarning) return 'text-orange-600';
return 'text-green-600';
};
}, [usage.isBlocked, usage.isWarning]);
const getProgressColor = () => {
const progressColor = useMemo(() => {
if (usage.isBlocked) return 'bg-red-600';
if (usage.isWarning) return 'bg-orange-500';
return 'bg-green-600';
};
}, [usage.isBlocked, usage.isWarning]);
const getStatusIcon = () => {
const statusIcon = useMemo(() => {
if (usage.isBlocked) return <AlertCircle className="h-4 w-4" />;
if (usage.isWarning) return <AlertTriangle className="h-4 w-4" />;
return <Check className="h-4 w-4" />;
};
}, [usage.isBlocked, usage.isWarning]);
const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString();
@@ -300,8 +300,8 @@ function UsageDisplay({category, usage}: UsageDisplayProps) {
{usage.usage.toLocaleString()} / {limitText} emails this month
</p>
</div>
<div className={`flex items-center gap-2 ${getStatusColor()}`}>
{getStatusIcon()}
<div className={`flex items-center gap-2 ${statusColor}`}>
{statusIcon}
<span className="text-sm font-medium">
{usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`}
</span>
@@ -310,7 +310,7 @@ function UsageDisplay({category, usage}: UsageDisplayProps) {
{usage.limit !== null && (
<>
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={getProgressColor()} />
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={progressColor} />
{usage.isBlocked && (
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
@@ -338,4 +338,4 @@ function UsageDisplay({category, usage}: UsageDisplayProps) {
)}
</div>
);
}
});
+20 -10
View File
@@ -19,7 +19,7 @@ import {
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
interface DashboardLayoutProps {
children: React.ReactNode;
@@ -87,7 +87,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
}
}, [showProjectMenu, showUserMenu]);
const handleLogout = async () => {
const handleLogout = useCallback(async () => {
try {
// Call the logout endpoint to clear the cookie
await network.fetch('GET', '/auth/logout');
@@ -112,7 +112,21 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
await mutateUser(null, false);
await router.push('/auth/login');
}
};
}, [mutateUser, router]);
const handleToggleProjectMenu = useCallback(() => {
setShowProjectMenu(prev => !prev);
}, []);
const handleToggleUserMenu = useCallback(() => {
setShowUserMenu(prev => !prev);
}, []);
const handleLogoutClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void handleLogout();
}, [handleLogout]);
return (
<div className="flex h-screen bg-neutral-50">
@@ -128,7 +142,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="p-4 border-b border-neutral-200">
<div className="relative" ref={projectMenuRef}>
<button
onClick={() => setShowProjectMenu(!showProjectMenu)}
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">
@@ -219,7 +233,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
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" />
@@ -231,11 +245,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
{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={(e) => {
e.preventDefault();
e.stopPropagation();
void handleLogout();
}}
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" />
+138 -61
View File
@@ -22,7 +22,7 @@ import {
Variable,
} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {useEffect, useState} from 'react';
import {useCallback, useEffect, useState} from 'react';
interface ToolbarProps {
editor: Editor | null;
@@ -56,11 +56,9 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
};
}, [editor]);
if (!editor) {
return null;
}
const addLink = () => {
// Define all callbacks BEFORE conditional return (Rules of Hooks)
const addLink = useCallback(() => {
if (!editor) return;
if (linkUrl) {
// If updating an existing link, extend selection to cover the entire link first
if (editor.isActive('link')) {
@@ -71,26 +69,107 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
setLinkUrl('');
setShowLinkInput(false);
}
};
}, [editor, linkUrl]);
const removeLink = () => {
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().unsetLink().run();
setLinkUrl('');
setShowLinkInput(false);
};
}, [editor]);
const setColor = (color: string) => {
const setColor = useCallback((color: string) => {
if (!editor) return;
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
};
}, [editor]);
const applyCustomColor = () => {
const applyCustomColor = useCallback(() => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
setColor(customColor);
setCustomColor('');
setShowColorPicker(false);
}
};
}, [customColor, setColor]);
// Editor command callbacks (memoized to avoid recreating on every render)
const handleUndo = useCallback(() => {
if (!editor) return;
editor.chain().focus().undo().run();
}, [editor]);
const handleRedo = useCallback(() => {
if (!editor) return;
editor.chain().focus().redo().run();
}, [editor]);
const handleBold = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleBold().run();
}, [editor]);
const handleItalic = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleItalic().run();
}, [editor]);
const handleStrike = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleStrike().run();
}, [editor]);
const handleCode = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleCode().run();
}, [editor]);
const handleHeading1 = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleHeading({level: 1}).run();
}, [editor]);
const handleHeading2 = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleHeading({level: 2}).run();
}, [editor]);
const handleHeading3 = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleHeading({level: 3}).run();
}, [editor]);
const handleBulletList = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleBulletList().run();
}, [editor]);
const handleOrderedList = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleOrderedList().run();
}, [editor]);
const handleBlockquote = useCallback(() => {
if (!editor) return;
editor.chain().focus().toggleBlockquote().run();
}, [editor]);
const handleAlignLeft = useCallback(() => {
if (!editor) return;
editor.chain().focus().setTextAlign('left').run();
}, [editor]);
const handleAlignCenter = useCallback(() => {
if (!editor) return;
editor.chain().focus().setTextAlign('center').run();
}, [editor]);
const handleAlignRight = useCallback(() => {
if (!editor) return;
editor.chain().focus().setTextAlign('right').run();
}, [editor]);
const handleAlignJustify = useCallback(() => {
if (!editor) return;
editor.chain().focus().setTextAlign('justify').run();
}, [editor]);
const handleToggleColorPicker = useCallback(() => setShowColorPicker(!showColorPicker), [showColorPicker]);
const handleToggleLinkInput = useCallback(() => {
if (!editor) return;
if (editor.isActive('link')) {
// Get the current link URL and show the input to edit it
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}, [editor, showLinkInput]);
// Tailwind color palette organized by hue
const colorGroups = [
@@ -128,6 +207,11 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
},
];
// Conditional return AFTER all hooks (Rules of Hooks)
if (!editor) {
return null;
}
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
{/* History */}
@@ -137,7 +221,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
onClick={handleUndo}
disabled={!editor.can().undo()}
className="h-8 w-8"
>
@@ -148,7 +232,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
onClick={handleRedo}
disabled={!editor.can().redo()}
className="h-8 w-8"
>
@@ -163,7 +247,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBold().run()}
onClick={handleBold}
data-active={editor.isActive('bold')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -174,7 +258,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleItalic().run()}
onClick={handleItalic}
data-active={editor.isActive('italic')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -185,7 +269,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleStrike().run()}
onClick={handleStrike}
data-active={editor.isActive('strike')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -196,7 +280,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleCode().run()}
onClick={handleCode}
data-active={editor.isActive('code')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -211,7 +295,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
onClick={handleHeading1}
data-active={editor.isActive('heading', {level: 1})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -222,7 +306,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
onClick={handleHeading2}
data-active={editor.isActive('heading', {level: 2})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -233,7 +317,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 3}).run()}
onClick={handleHeading3}
data-active={editor.isActive('heading', {level: 3})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -248,7 +332,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBulletList().run()}
onClick={handleBulletList}
data-active={editor.isActive('bulletList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -259,7 +343,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
onClick={handleOrderedList}
data-active={editor.isActive('orderedList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -270,7 +354,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
onClick={handleBlockquote}
data-active={editor.isActive('blockquote')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -285,7 +369,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
onClick={handleAlignLeft}
data-active={editor.isActive({textAlign: 'left'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -296,7 +380,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
onClick={handleAlignCenter}
data-active={editor.isActive({textAlign: 'center'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -307,7 +391,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
onClick={handleAlignRight}
data-active={editor.isActive({textAlign: 'right'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -318,7 +402,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
onClick={handleAlignJustify}
data-active={editor.isActive({textAlign: 'justify'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -333,7 +417,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowColorPicker(!showColorPicker)}
onClick={handleToggleColorPicker}
className="h-8 w-8"
>
<Palette className="h-4 w-4" />
@@ -379,26 +463,29 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
<div key={group.name}>
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
<div className="grid grid-cols-7 gap-1.5">
{group.colors.map(color => (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => {
setColor(color);
setShowColorPicker(false);
}}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-110 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
))}
{group.colors.map(color => {
const handleColorClick = () => {
setColor(color);
setShowColorPicker(false);
};
return (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={handleColorClick}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-110 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
);
})}
</div>
</div>
))}
@@ -414,17 +501,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => {
if (editor.isActive('link')) {
// Get the current link URL and show the input to edit it
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}}
onClick={handleToggleLinkInput}
data-active={editor.isActive('link')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
@@ -1,7 +1,7 @@
import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} 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} from 'react';
import {useState, useEffect, useMemo, useCallback, memo} from 'react';
import {network} from '../lib/network';
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
@@ -115,7 +115,7 @@ interface FilterRowProps {
availableFields: FieldOption[];
}
function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) {
const FilterRow = memo(function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
@@ -123,13 +123,13 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
// Get field type from available fields
const fieldOption = availableFields.find(f => f.value === 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';
// Get operators based on field type
const getOperators = () => {
// Get operators based on field type (memoized)
const operators = useMemo(() => {
if (isEventOrEmailActivity) {
return EVENT_OPERATORS;
}
@@ -151,9 +151,9 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
}, [fieldType, isEventOrEmailActivity]);
const handleFieldChange = (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';
@@ -208,10 +208,10 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
setOpen(false);
setSearch('');
};
}, [availableFields, filter.operator, fieldType, onChange]);
// Helper to get default value based on field type
const getDefaultValueForType = (type: string) => {
const getDefaultValueForType = useCallback((type: string) => {
switch (type) {
case 'boolean':
return true;
@@ -222,10 +222,10 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
default:
return '';
}
};
}, []);
// Helper to get valid operators for a field type
const getOperatorsForType = (type: string, isEvent: boolean) => {
const getOperatorsForType = useCallback((type: string, isEvent: boolean) => {
if (isEvent) {
return EVENT_OPERATORS;
}
@@ -246,34 +246,38 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
}, []);
// Get label for selected field
const getFieldLabel = () => {
const getFieldLabel = useCallback(() => {
const field = availableFields.find(f => f.value === filter.field);
return field?.label || filter.field;
};
}, [availableFields, filter.field]);
// Group fields by category for display
const groupedFields = availableFields.reduce<Record<string, FieldOption[]>>((acc, field) => {
if (!acc[field.category]) {
acc[field.category] = [];
}
acc[field.category]!.push(field);
return acc;
}, {});
// Group fields by category for display (memoized to avoid expensive reduce on every render)
const groupedFields = useMemo(() => {
return availableFields.reduce<Record<string, FieldOption[]>>((acc, field) => {
if (!acc[field.category]) {
acc[field.category] = [];
}
acc[field.category]!.push(field);
return acc;
}, {});
}, [availableFields]);
// Filter fields based on search
const filteredGroups = 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[]>);
// 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[]>);
}, [groupedFields, search]);
return (
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200">
@@ -391,7 +395,7 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
<SelectValue />
</SelectTrigger>
<SelectContent>
{getOperators().map(op => (
{operators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
@@ -474,7 +478,7 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
</Button>
</div>
);
}
});
interface FilterGroupComponentProps {
group: FilterGroup;
@@ -485,28 +489,28 @@ interface FilterGroupComponentProps {
}
function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFields}: FilterGroupComponentProps) {
const addFilter = () => {
const addFilter = useCallback(() => {
onChange({
...group,
filters: [...group.filters, {field: 'email', operator: 'contains', value: ''}],
});
};
}, [group, onChange]);
const updateFilter = (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]);
const removeFilter = (index: number) => {
const removeFilter = useCallback((index: number) => {
onChange({
...group,
filters: group.filters.filter((_, i) => i !== index),
});
};
}, [group, onChange]);
const addNestedCondition = () => {
const addNestedCondition = useCallback(() => {
onChange({
...group,
conditions: {
@@ -514,19 +518,19 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
groups: [{filters: [{field: 'email', operator: 'contains', value: ''}]}],
},
});
};
}, [group, onChange]);
const updateNestedCondition = (condition: FilterCondition) => {
const updateNestedCondition = useCallback((condition: FilterCondition) => {
onChange({
...group,
conditions: condition,
});
};
}, [group, onChange]);
const removeNestedCondition = () => {
const removeNestedCondition = useCallback(() => {
const {conditions, ...rest} = group;
onChange(rest);
};
}, [group, onChange]);
const bgColors = ['bg-white', 'bg-blue-50/50', 'bg-purple-50/50', 'bg-green-50/50'];
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
@@ -547,7 +551,7 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
<div className="space-y-3">
{group.filters.map((filter, index) => (
<FilterRow key={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 && (
@@ -593,33 +597,33 @@ interface FilterConditionComponentProps {
}
function FilterConditionComponent({condition, onChange, depth = 0, availableFields}: FilterConditionComponentProps) {
const addGroup = () => {
const addGroup = useCallback(() => {
onChange({
...condition,
groups: [...condition.groups, {filters: [{field: 'email', operator: 'contains', value: ''}]}],
});
};
}, [condition, onChange]);
const updateGroup = (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]);
const removeGroup = (index: number) => {
const removeGroup = useCallback((index: number) => {
onChange({
...condition,
groups: condition.groups.filter((_, i) => i !== index),
});
};
}, [condition, onChange]);
const toggleLogic = () => {
const toggleLogic = useCallback(() => {
onChange({
...condition,
logic: condition.logic === 'AND' ? 'OR' : 'AND',
});
};
}, [condition, onChange]);
return (
<div className="space-y-4">
@@ -640,7 +644,7 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
<div className="space-y-4">
{condition.groups.map((group, index) => (
<div key={index}>
<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>
+9 -7
View File
@@ -357,10 +357,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
template: step.template,
config: step.config,
onEdit: () => handleEditStep(step.id),
onDelete: () => {
setStepToDelete(step.id);
setShowDeleteDialog(true);
},
onDelete: () => handleDeleteStepClick(step.id),
},
};
});
@@ -420,7 +417,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
});
return nodes;
}, [steps]);
}, [steps, handleEditStep, handleDeleteStepClick]);
// Convert transitions to React Flow edges
const rawEdges: Edge[] = useMemo(() => {
@@ -625,11 +622,16 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
[addStepContext, workflowId, onUpdate],
);
const handleEditStep = (stepId: string) => {
const handleEditStep = useCallback((stepId: string) => {
// This will be handled by the parent component
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
window.dispatchEvent(event);
};
}, []);
const handleDeleteStepClick = useCallback((stepId: string) => {
setStepToDelete(stepId);
setShowDeleteDialog(true);
}, []);
// Get all steps that will be affected by deleting a step (the step itself + all downstream steps)
const getAffectedSteps = useCallback(