Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/web/src/components/TemplateSearchPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {useCallback, useEffect, useRef, useState} from 'react';
import useSWR from 'swr';

interface TemplateSearchPickerProps {
/** Currently selected template ID */
value: string;
/** Display name for the pre-selected template (avoids a fetch just to show the name) */
initialName?: string;
onChange: (id: string) => void;
}

/**
* Inline combobox for picking a template.
* Fires a debounced server-side search (/templates?search=…&pageSize=20)
* so it works correctly regardless of how many templates exist.
*/
export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) {
const [query, setQuery] = useState(initialName ?? '');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// When the dialog re-opens with an existing selection, sync the display name
useEffect(() => {
setQuery(initialName ?? '');
}, [initialName]);

const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedQuery(val), 300);
}, []);

const {data, isLoading} = useSWR<PaginatedResponse<Template>>(
open || debouncedQuery
? `/templates?pageSize=20${debouncedQuery ? `&search=${encodeURIComponent(debouncedQuery)}` : ''}`
: null,
{revalidateOnFocus: false},
);

// When closed, show the selected template's name rather than the raw query
const displayValue = open
? query
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');

return (
<div className="relative">
<Input
type="text"
value={displayValue}
onChange={handleInput}
onFocus={() => {
setOpen(true);
setDebouncedQuery(query);
}}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…"
autoComplete="off"
/>

{open && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-sm text-neutral-500">Searching…</div>
) : !data?.data.length ? (
<div className="px-3 py-2 text-sm text-neutral-500">No templates found</div>
) : (
<Command>
<CommandList>
<CommandGroup>
{data.data.map(t => (
<CommandItem
key={t.id}
value={t.id}
onSelect={() => {
onChange(t.id);
setQuery(t.name);
setOpen(false);
}}
>
<span className="flex-1 truncate">{t.name}</span>
<span className="ml-2 text-xs text-neutral-400 shrink-0">{t.type}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
{(data?.total ?? 0) > 20 && (
<div className="px-3 py-1.5 text-xs text-neutral-400 border-t border-neutral-100">
Showing 20 of {data!.total} — type to narrow results
</div>
)}
</div>
)}
</div>
);
}
37 changes: 5 additions & 32 deletions apps/web/src/pages/workflows/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
import {ReactFlowProvider} from '@xyflow/react';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
Expand Down Expand Up @@ -1004,7 +1005,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo

const [isSubmitting, setIsSubmitting] = useState(false);

const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100');
// templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);

// Fetch available event names when dialog opens
Expand Down Expand Up @@ -1340,21 +1341,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<Label htmlFor="template" className="text-sm font-medium">
Email Template *
</Label>
<Select value={templateId} onValueChange={setTemplateId} required>
<SelectTrigger id="template" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<TemplateSearchPicker value={templateId} onChange={setTemplateId} />
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div>

Expand Down Expand Up @@ -2073,7 +2060,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// EXIT fields
const [exitReason, setExitReason] = useState(String(config?.reason || 'completed'));

const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100');
// templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);

// Fetch available event names when dialog opens
Expand Down Expand Up @@ -2359,21 +2346,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<Label htmlFor="editTemplate" className="text-sm font-medium">
Email Template *
</Label>
<Select value={templateId} onValueChange={setTemplateId} required>
<SelectTrigger id="editTemplate" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div>

Expand Down