Fix lint errors
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Workflow,
|
Workflow,
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -125,156 +124,154 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
setShowUserMenu(prev => !prev);
|
setShowUserMenu(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogoutClick = useCallback((e: React.MouseEvent) => {
|
const handleLogoutClick = useCallback(
|
||||||
e.preventDefault();
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
void handleLogout();
|
e.stopPropagation();
|
||||||
}, [handleLogout]);
|
void handleLogout();
|
||||||
|
},
|
||||||
|
[handleLogout],
|
||||||
|
);
|
||||||
|
|
||||||
// Sidebar content (reusable for both desktop and mobile)
|
// Sidebar content (reusable for both desktop and mobile)
|
||||||
const SidebarContent = () => (
|
const sidebarContent = (
|
||||||
<>
|
<>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
||||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Switcher */}
|
{/* Project Switcher */}
|
||||||
<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={handleToggleProjectMenu}
|
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="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
|
||||||
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Project Dropdown */}
|
|
||||||
{showProjectMenu && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
|
||||||
{availableProjects.map(project => (
|
|
||||||
<button
|
|
||||||
key={project.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveProject(project);
|
|
||||||
setShowProjectMenu(false);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
|
||||||
{project.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="text-neutral-900">{project.name}</span>
|
|
||||||
{activeProject?.id === project.id && (
|
|
||||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="border-t border-neutral-200 my-1" />
|
|
||||||
<Link
|
|
||||||
href="/projects/create"
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>Create project</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
|
||||||
{navigation.map((section, sectionIndex) => (
|
|
||||||
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
|
|
||||||
{section.title && (
|
|
||||||
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
|
||||||
{section.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{section.items.map(item => {
|
|
||||||
const isActive = router.pathname === item.href;
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
onClick={() => setShowMobileMenu(false)}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
|
||||||
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Settings & User Menu */}
|
|
||||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
onClick={() => setShowMobileMenu(false)}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
|
||||||
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Settings className="h-5 w-5" />
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
Settings
|
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||||
</Link>
|
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
||||||
|
|
||||||
<div className="relative" ref={userMenuRef}>
|
|
||||||
<button
|
|
||||||
onClick={handleToggleUserMenu}
|
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
|
||||||
>
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
<span className="flex-1 text-left truncate">{user?.email}</span>
|
|
||||||
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* User Dropdown */}
|
|
||||||
{showUserMenu && (
|
|
||||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
|
||||||
<button
|
|
||||||
onClick={handleLogoutClick}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
<span>Log out</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Project Dropdown */}
|
||||||
|
{showProjectMenu && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||||
|
{availableProjects.map(project => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveProject(project);
|
||||||
|
setShowProjectMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||||
|
{project.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-neutral-900">{project.name}</span>
|
||||||
|
{activeProject?.id === project.id && (
|
||||||
|
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-neutral-200 my-1" />
|
||||||
|
<Link
|
||||||
|
href="/projects/create"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Create project</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||||
|
{navigation.map((section, sectionIndex) => (
|
||||||
|
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
|
||||||
|
{section.title && (
|
||||||
|
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.items.map(item => {
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setShowMobileMenu(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||||
|
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Settings & User Menu */}
|
||||||
|
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
onClick={() => setShowMobileMenu(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||||
|
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="relative" ref={userMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleUserMenu}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
<span className="flex-1 text-left truncate">{user?.email}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
|
{showUserMenu && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||||
|
<button
|
||||||
|
onClick={handleLogoutClick}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-neutral-50">
|
<div className="flex h-screen bg-neutral-50">
|
||||||
{/* Desktop Sidebar - Hidden on mobile */}
|
{/* Desktop Sidebar - Hidden on mobile */}
|
||||||
<div className="hidden lg:flex w-64 bg-white border-r border-neutral-200 flex-col">
|
<div className="hidden lg:flex w-64 bg-white border-r border-neutral-200 flex-col">{sidebarContent}</div>
|
||||||
<SidebarContent />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
{showMobileMenu && (
|
{showMobileMenu && (
|
||||||
<div
|
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setShowMobileMenu(false)}>
|
||||||
className="fixed inset-0 z-40 lg:hidden"
|
|
||||||
onClick={() => setShowMobileMenu(false)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<div className="absolute inset-0 bg-black/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -285,9 +282,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
showMobileMenu ? 'translate-x-0' : '-translate-x-full'
|
showMobileMenu ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">{sidebarContent}</div>
|
||||||
<SidebarContent />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@plunk/ui';
|
||||||
import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types';
|
||||||
import {Plus, Trash2, GripVertical, Check, ChevronsUpDown, Search} from 'lucide-react';
|
import {Check, ChevronsUpDown, GripVertical, Plus, Search, Trash2} from 'lucide-react';
|
||||||
import {useState, useEffect, useMemo, useCallback, memo} from 'react';
|
import {memo, useCallback, useEffect, useMemo, useState} 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}[] = [
|
||||||
@@ -80,7 +92,10 @@ function useAvailableOptions() {
|
|||||||
if (name.startsWith('email.')) {
|
if (name.startsWith('email.')) {
|
||||||
emailOptions.push({
|
emailOptions.push({
|
||||||
value: name,
|
value: name,
|
||||||
label: name.replace('email.', '').replace(/([A-Z])/g, ' $1').trim(),
|
label: name
|
||||||
|
.replace('email.', '')
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.trim(),
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
category: 'Email Activity' as const,
|
category: 'Email Activity' as const,
|
||||||
});
|
});
|
||||||
@@ -140,20 +155,28 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
return STANDARD_OPERATORS.filter(op =>
|
return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'exists', 'notExists'].includes(op.value));
|
||||||
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'number' || type === 'date') {
|
if (type === 'number' || type === 'date') {
|
||||||
return STANDARD_OPERATORS.filter(op =>
|
return STANDARD_OPERATORS.filter(op =>
|
||||||
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
|
[
|
||||||
|
'equals',
|
||||||
|
'notEquals',
|
||||||
|
'greaterThan',
|
||||||
|
'lessThan',
|
||||||
|
'greaterThanOrEqual',
|
||||||
|
'lessThanOrEqual',
|
||||||
|
'exists',
|
||||||
|
'notExists',
|
||||||
|
'within',
|
||||||
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// String type - no within operator, no comparison operators
|
// String type - no within operator, no comparison operators
|
||||||
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),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -161,7 +184,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
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 = useMemo(() => availableFields.find(f => f.value === filter.field), [availableFields, filter.field]);
|
const fieldOption = useMemo(
|
||||||
|
() => availableFields.find(f => f.value === filter.field),
|
||||||
|
[availableFields, filter.field],
|
||||||
|
);
|
||||||
const fieldType = fieldOption?.type || 'string';
|
const fieldType = fieldOption?.type || 'string';
|
||||||
|
|
||||||
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
|
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
|
||||||
@@ -174,79 +200,90 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
|
|
||||||
// Filter operators based on field type
|
// Filter operators based on field type
|
||||||
if (fieldType === 'boolean') {
|
if (fieldType === 'boolean') {
|
||||||
return STANDARD_OPERATORS.filter(op =>
|
return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'exists', 'notExists'].includes(op.value));
|
||||||
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldType === 'number' || fieldType === 'date') {
|
if (fieldType === 'number' || fieldType === 'date') {
|
||||||
return STANDARD_OPERATORS.filter(op =>
|
return STANDARD_OPERATORS.filter(op =>
|
||||||
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
|
[
|
||||||
|
'equals',
|
||||||
|
'notEquals',
|
||||||
|
'greaterThan',
|
||||||
|
'lessThan',
|
||||||
|
'greaterThanOrEqual',
|
||||||
|
'lessThanOrEqual',
|
||||||
|
'exists',
|
||||||
|
'notExists',
|
||||||
|
'within',
|
||||||
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// String type - no within operator, no comparison operators
|
// String type - no within operator, no comparison operators
|
||||||
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]);
|
}, [fieldType, isEventOrEmailActivity]);
|
||||||
|
|
||||||
const handleFieldChange = useCallback((value: string) => {
|
const handleFieldChange = useCallback(
|
||||||
const selectedField = availableFields.find(f => f.value === value);
|
(value: string) => {
|
||||||
const newFieldType = selectedField?.type || 'string';
|
const selectedField = availableFields.find(f => f.value === value);
|
||||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
const newFieldType = selectedField?.type || 'string';
|
||||||
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
||||||
|
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
||||||
|
|
||||||
// Determine default operator and value based on new field type
|
// Determine default operator and value based on new field type
|
||||||
let newOperator = filter.operator;
|
let newOperator = filter.operator;
|
||||||
let newValue: string | number | boolean | undefined = undefined;
|
let newValue: string | number | boolean | undefined = undefined;
|
||||||
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
|
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
|
||||||
|
|
||||||
if (isEvent && !currentOperatorIsEvent) {
|
if (isEvent && !currentOperatorIsEvent) {
|
||||||
// Switching to event field
|
// Switching to event field
|
||||||
newOperator = 'triggered';
|
newOperator = 'triggered';
|
||||||
newValue = undefined;
|
newValue = undefined;
|
||||||
newUnit = undefined;
|
newUnit = undefined;
|
||||||
} else if (!isEvent && currentOperatorIsEvent) {
|
} else if (!isEvent && currentOperatorIsEvent) {
|
||||||
// Switching from event to non-event field
|
// Switching from event to non-event field
|
||||||
newOperator = 'equals';
|
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
|
||||||
newUnit = undefined;
|
|
||||||
} else if (fieldType !== newFieldType) {
|
|
||||||
// Field type changed (e.g., date to boolean, number to string)
|
|
||||||
// Check if current operator is valid for new type
|
|
||||||
const validOperators = getOperatorsForType(newFieldType, isEvent);
|
|
||||||
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
|
|
||||||
|
|
||||||
if (!isOperatorValid) {
|
|
||||||
newOperator = 'equals';
|
newOperator = 'equals';
|
||||||
}
|
|
||||||
|
|
||||||
// Reset value to appropriate default for new type
|
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
|
||||||
|
|
||||||
// Always clear unit when changing field types, even if operator is still valid
|
|
||||||
// This handles cases like switching from date "within" to string field
|
|
||||||
newUnit = undefined;
|
|
||||||
|
|
||||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
|
||||||
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
|
||||||
if (!newOperatorNeedsUnit) {
|
|
||||||
// Convert numeric value back to appropriate type for the field
|
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
newValue = getDefaultValueForType(newFieldType);
|
||||||
|
newUnit = undefined;
|
||||||
|
} else if (fieldType !== newFieldType) {
|
||||||
|
// Field type changed (e.g., date to boolean, number to string)
|
||||||
|
// Check if current operator is valid for new type
|
||||||
|
const validOperators = getOperatorsForType(newFieldType, isEvent);
|
||||||
|
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
|
||||||
|
|
||||||
|
if (!isOperatorValid) {
|
||||||
|
newOperator = 'equals';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset value to appropriate default for new type
|
||||||
|
newValue = getDefaultValueForType(newFieldType);
|
||||||
|
|
||||||
|
// Always clear unit when changing field types, even if operator is still valid
|
||||||
|
// This handles cases like switching from date "within" to string field
|
||||||
|
newUnit = undefined;
|
||||||
|
|
||||||
|
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
||||||
|
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
||||||
|
if (!newOperatorNeedsUnit) {
|
||||||
|
// Convert numeric value back to appropriate type for the field
|
||||||
|
newValue = getDefaultValueForType(newFieldType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
field: value,
|
field: value,
|
||||||
operator: newOperator,
|
operator: newOperator,
|
||||||
value: newValue,
|
value: newValue,
|
||||||
unit: newUnit,
|
unit: newUnit,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}, [availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType]);
|
},
|
||||||
|
[availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType],
|
||||||
|
);
|
||||||
|
|
||||||
// Get label for selected field
|
// Get label for selected field
|
||||||
const getFieldLabel = useCallback(() => {
|
const getFieldLabel = useCallback(() => {
|
||||||
@@ -267,16 +304,20 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
|
|
||||||
// Filter fields based on search (memoized to avoid expensive filter on every keystroke)
|
// Filter fields based on search (memoized to avoid expensive filter on every keystroke)
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
return Object.entries(groupedFields).reduce((acc, [category, fields]) => {
|
return Object.entries(groupedFields).reduce(
|
||||||
const filtered = fields.filter(f =>
|
(acc, [category, fields]) => {
|
||||||
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
const filtered = fields.filter(
|
||||||
f.value.toLowerCase().includes(search.toLowerCase())
|
f =>
|
||||||
);
|
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
if (filtered.length > 0) {
|
f.value.toLowerCase().includes(search.toLowerCase()),
|
||||||
acc[category] = filtered;
|
);
|
||||||
}
|
if (filtered.length > 0) {
|
||||||
return acc;
|
acc[category] = filtered;
|
||||||
}, {} as Record<string, FieldOption[]>);
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, FieldOption[]>,
|
||||||
|
);
|
||||||
}, [groupedFields, search]);
|
}, [groupedFields, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -303,21 +344,17 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Search fields, events, or email activity..."
|
placeholder="Search fields, events, or email activity..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||||
{Object.keys(filteredGroups).length === 0 ? (
|
{Object.keys(filteredGroups).length === 0 ? (
|
||||||
<div className="py-6 text-center text-sm text-neutral-500">
|
<div className="py-6 text-center text-sm text-neutral-500">No fields or events found.</div>
|
||||||
No fields or events found.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
Object.entries(filteredGroups).map(([category, fields]) => (
|
Object.entries(filteredGroups).map(([category, fields]) => (
|
||||||
<div key={category} className="py-1">
|
<div key={category} className="py-1">
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">
|
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
|
||||||
{category}
|
|
||||||
</div>
|
|
||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
<button
|
<button
|
||||||
key={field.value}
|
key={field.value}
|
||||||
@@ -325,9 +362,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
className="w-full flex items-center rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-100 cursor-pointer text-left"
|
className="w-full flex items-center rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-100 cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${filter.field === field.value ? 'opacity-100' : 'opacity-0'}`}
|
||||||
filter.field === field.value ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -420,7 +455,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
className="text-sm flex-1"
|
className="text-sm flex-1"
|
||||||
min="1"
|
min="1"
|
||||||
/>
|
/>
|
||||||
<Select value={filter.unit || 'days'} onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}>
|
<Select
|
||||||
|
value={filter.unit || 'days'}
|
||||||
|
onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}
|
||||||
|
>
|
||||||
<SelectTrigger className="text-sm w-[110px]">
|
<SelectTrigger className="text-sm w-[110px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -434,7 +472,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : fieldType === 'boolean' ? (
|
) : fieldType === 'boolean' ? (
|
||||||
<Select value={String(filter.value ?? 'true')} onValueChange={v => onChange({...filter, value: v === 'true'})}>
|
<Select
|
||||||
|
value={String(filter.value ?? 'true')}
|
||||||
|
onValueChange={v => onChange({...filter, value: v === 'true'})}
|
||||||
|
>
|
||||||
<SelectTrigger className="text-sm">
|
<SelectTrigger className="text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -473,7 +514,13 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,19 +543,25 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|||||||
});
|
});
|
||||||
}, [group, onChange]);
|
}, [group, onChange]);
|
||||||
|
|
||||||
const updateFilter = useCallback((index: number, filter: SegmentFilter) => {
|
const updateFilter = useCallback(
|
||||||
onChange({
|
(index: number, filter: SegmentFilter) => {
|
||||||
...group,
|
onChange({
|
||||||
filters: group.filters.map((f, i) => (i === index ? filter : f)),
|
...group,
|
||||||
});
|
filters: group.filters.map((f, i) => (i === index ? filter : f)),
|
||||||
}, [group, onChange]);
|
});
|
||||||
|
},
|
||||||
|
[group, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const removeFilter = useCallback((index: number) => {
|
const removeFilter = useCallback(
|
||||||
onChange({
|
(index: number) => {
|
||||||
...group,
|
onChange({
|
||||||
filters: group.filters.filter((_, i) => i !== index),
|
...group,
|
||||||
});
|
filters: group.filters.filter((_, i) => i !== index),
|
||||||
}, [group, onChange]);
|
});
|
||||||
|
},
|
||||||
|
[group, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const addNestedCondition = useCallback(() => {
|
const addNestedCondition = useCallback(() => {
|
||||||
onChange({
|
onChange({
|
||||||
@@ -520,15 +573,19 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|||||||
});
|
});
|
||||||
}, [group, onChange]);
|
}, [group, onChange]);
|
||||||
|
|
||||||
const updateNestedCondition = useCallback((condition: FilterCondition) => {
|
const updateNestedCondition = useCallback(
|
||||||
onChange({
|
(condition: FilterCondition) => {
|
||||||
...group,
|
onChange({
|
||||||
conditions: condition,
|
...group,
|
||||||
});
|
conditions: condition,
|
||||||
}, [group, onChange]);
|
});
|
||||||
|
},
|
||||||
|
[group, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const removeNestedCondition = useCallback(() => {
|
const removeNestedCondition = useCallback(() => {
|
||||||
const {conditions, ...rest} = group;
|
const rest = {...group};
|
||||||
|
delete rest.conditions;
|
||||||
onChange(rest);
|
onChange(rest);
|
||||||
}, [group, onChange]);
|
}, [group, onChange]);
|
||||||
|
|
||||||
@@ -536,14 +593,22 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|||||||
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
|
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}>
|
<div
|
||||||
|
className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GripVertical className="h-4 w-4 text-neutral-400" />
|
<GripVertical className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
|
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
|
||||||
</div>
|
</div>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -551,7 +616,13 @@ 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={`${filter.field}-${filter.operator}-${index}`} filter={filter} onChange={f => updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} />
|
<FilterRow
|
||||||
|
key={`${filter.field}-${filter.operator}-${index}`}
|
||||||
|
filter={filter}
|
||||||
|
onChange={f => updateFilter(index, f)}
|
||||||
|
onRemove={() => removeFilter(index)}
|
||||||
|
availableFields={availableFields}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{group.conditions && (
|
{group.conditions && (
|
||||||
@@ -568,7 +639,12 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
|
|||||||
Remove nested
|
Remove nested
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FilterConditionComponent condition={group.conditions} onChange={updateNestedCondition} depth={depth + 1} availableFields={availableFields} />
|
<FilterConditionComponent
|
||||||
|
condition={group.conditions}
|
||||||
|
onChange={updateNestedCondition}
|
||||||
|
depth={depth + 1}
|
||||||
|
availableFields={availableFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -604,19 +680,25 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
|
|||||||
});
|
});
|
||||||
}, [condition, onChange]);
|
}, [condition, onChange]);
|
||||||
|
|
||||||
const updateGroup = useCallback((index: number, group: FilterGroup) => {
|
const updateGroup = useCallback(
|
||||||
onChange({
|
(index: number, group: FilterGroup) => {
|
||||||
...condition,
|
onChange({
|
||||||
groups: condition.groups.map((g, i) => (i === index ? group : g)),
|
...condition,
|
||||||
});
|
groups: condition.groups.map((g, i) => (i === index ? group : g)),
|
||||||
}, [condition, onChange]);
|
});
|
||||||
|
},
|
||||||
|
[condition, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const removeGroup = useCallback((index: number) => {
|
const removeGroup = useCallback(
|
||||||
onChange({
|
(index: number) => {
|
||||||
...condition,
|
onChange({
|
||||||
groups: condition.groups.filter((_, i) => i !== index),
|
...condition,
|
||||||
});
|
groups: condition.groups.filter((_, i) => i !== index),
|
||||||
}, [condition, onChange]);
|
});
|
||||||
|
},
|
||||||
|
[condition, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleLogic = useCallback(() => {
|
const toggleLogic = useCallback(() => {
|
||||||
onChange({
|
onChange({
|
||||||
@@ -647,7 +729,9 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
|
|||||||
<div key={`group-${depth}-${index}-${group.filters.length}`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FilterGroupComponent
|
<FilterGroupComponent
|
||||||
|
|||||||
@@ -8,28 +8,11 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Contact, Segment} from '@plunk/db';
|
import type {Contact, Segment} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {
|
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||||
ArrowLeft,
|
|
||||||
Calendar,
|
|
||||||
Database,
|
|
||||||
Filter,
|
|
||||||
MailCheck,
|
|
||||||
MailX,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Save,
|
|
||||||
Trash2,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -86,10 +69,12 @@ export default function SegmentDetailPage() {
|
|||||||
setName(segment.name);
|
setName(segment.name);
|
||||||
setDescription(segment.description || '');
|
setDescription(segment.description || '');
|
||||||
setTrackMembership(segment.trackMembership);
|
setTrackMembership(segment.trackMembership);
|
||||||
setCondition((segment.condition as unknown as FilterCondition) || {
|
setCondition(
|
||||||
logic: 'AND',
|
(segment.condition as unknown as FilterCondition) || {
|
||||||
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
|
logic: 'AND',
|
||||||
});
|
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [segment]);
|
}, [segment]);
|
||||||
|
|
||||||
@@ -144,7 +129,6 @@ export default function SegmentDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {toast} from 'sonner';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||||
import {ReactFlowProvider} from '@xyflow/react';
|
import {ReactFlowProvider} from '@xyflow/react';
|
||||||
import {ContactSchemas, WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
|
|
||||||
interface WorkflowWithDetails extends Workflow {
|
interface WorkflowWithDetails extends Workflow {
|
||||||
steps: (WorkflowStep & {
|
steps: (WorkflowStep & {
|
||||||
@@ -411,7 +411,9 @@ export default function WorkflowEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{workflow.description && <p className="text-neutral-500 mt-1 text-sm sm:text-base">{workflow.description}</p>}
|
{workflow.description && (
|
||||||
|
<p className="text-neutral-500 mt-1 text-sm sm:text-base">{workflow.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user