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