feat: add search functionality to campaigns list with debounce effect

This commit is contained in:
Dries Augustyns
2026-05-09 09:10:17 +02:00
parent fadc19d139
commit d11495061d
3 changed files with 115 additions and 80 deletions
+2
View File
@@ -64,6 +64,7 @@ export class Campaigns {
private async list(req: Request, res: Response, _next: NextFunction) { private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined; const status = req.query.status as CampaignStatus | undefined;
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20; const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -74,6 +75,7 @@ export class Campaigns {
const result = await CampaignService.list(auth.projectId, { const result = await CampaignService.list(auth.projectId, {
status, status,
search,
page, page,
pageSize, pageSize,
}); });
+11 -1
View File
@@ -186,16 +186,26 @@ export class CampaignService {
projectId: string, projectId: string,
options: { options: {
status?: CampaignStatus; status?: CampaignStatus;
search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} = {}, } = {},
): Promise<PaginatedResponse<Campaign>> { ): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options; const {status, search, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = { const where: Prisma.CampaignWhereInput = {
projectId, projectId,
...(status ? {status} : {}), ...(status ? {status} : {}),
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{subject: {contains: search, mode: 'insensitive' as const}},
{from: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
}; };
const [campaigns, total] = await Promise.all([ const [campaigns, total] = await Promise.all([
+102 -79
View File
@@ -8,11 +8,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
Select, Input,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Template} from '@plunk/db'; import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db'; import {CampaignStatus} from '@plunk/db';
@@ -23,11 +19,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog'; import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react'; import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useState} from 'react'; import {useEffect, useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -35,7 +31,9 @@ import dayjs from 'dayjs';
export default function CampaignsPage() { export default function CampaignsPage() {
const router = useRouter(); const router = useRouter();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL'); const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null); const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -44,10 +42,18 @@ export default function CampaignsPage() {
const [showCampaignDialog, setShowCampaignDialog] = useState(false); const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>( const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, `/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const getStatusBadge = (status: CampaignStatus) => { const getStatusBadge = (status: CampaignStatus) => {
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = { const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
DRAFT: {label: 'Draft', variant: 'neutral'}, DRAFT: {label: 'Draft', variant: 'neutral'},
@@ -245,21 +251,45 @@ export default function CampaignsPage() {
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Filters */} {/* Search & Filters */}
<div className="w-56"> <div className="flex flex-col sm:flex-row gap-3">
<Select value={statusFilter} onValueChange={setStatusFilter}> <div className="relative flex-1">
<SelectTrigger> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<SelectValue placeholder="All Statuses" /> <Input
</SelectTrigger> type="text"
<SelectContent> placeholder="Search campaigns..."
<SelectItem value="ALL">All Statuses</SelectItem> value={searchInput}
<SelectItem value="DRAFT">Draft</SelectItem> onChange={e => setSearchInput(e.target.value)}
<SelectItem value="SCHEDULED">Scheduled</SelectItem> className="pl-10 pr-10"
<SelectItem value="SENDING">Sending</SelectItem> />
<SelectItem value="SENT">Sent</SelectItem> {searchInput && (
<SelectItem value="CANCELLED">Cancelled</SelectItem> <button
</SelectContent> type="button"
</Select> aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="flex gap-1.5 shrink-0 flex-wrap">
{(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => (
<Button
key={status}
type="button"
onClick={() => { setStatusFilter(status); setPage(1); }}
variant={statusFilter === status ? 'default' : 'secondary'}
size="sm"
>
{status === 'ALL' ? 'All' : status.charAt(0) + status.slice(1).toLowerCase()}
</Button>
))}
</div>
</div> </div>
{/* Campaigns List */} {/* Campaigns List */}
@@ -275,66 +305,59 @@ export default function CampaignsPage() {
<CardContent> <CardContent>
<EmptyState <EmptyState
icon={Mail} icon={Mail}
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'} title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
description={ description={
statusFilter !== 'ALL' search
? 'Adjust your filters or create a new campaign.' ? 'Try a different search term.'
: 'Send one-off emails to groups of contacts.' : statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.'
} }
action={ action={
statusFilter === 'ALL' ? ( <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button>
<Button>
<Plus className="h-4 w-4" />
Create Campaign
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button asChild>
<Link href="/campaigns/create">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Campaign Create Campaign
</Link> <ChevronDown className="h-4 w-4 ml-1" />
</Button> </Button>
) </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
} }
/> />
</CardContent> </CardContent>