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 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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user