From a981a8bba468bbc3268812653597e1f306a3a7f9 Mon Sep 17 00:00:00 2001 From: Doug Beatty <123423698+wcatbb@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:12:56 -0500 Subject: [PATCH 1/4] Improve template template loading and debounced search --- apps/web/src/pages/workflows/[id].tsx | 125 +++++++++++++++++++------- 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 4288b89..b3c38e8 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -56,7 +56,7 @@ import { } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; @@ -1004,7 +1004,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo const [isSubmitting, setIsSubmitting] = useState(false); - const {data: templatesData} = useSWR>('/templates?pageSize=100'); + // templates fetched on-demand by TemplateSearchPicker const {data: workflow} = useSWR(workflowId ? `/workflows/${workflowId}` : null); // Fetch available event names when dialog opens @@ -1340,21 +1340,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo - +

The email template to use for this step

@@ -1872,6 +1858,93 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo ); } +// TemplateSearchPicker — server-side debounced search, replaces the static { setOpen(true); setDebouncedQuery(query); }} + onBlur={() => setTimeout(() => setOpen(false), 150)} + placeholder="Search templates…" + autoComplete="off" + /> + {open && ( +
+ {isLoading ? ( +
Searching…
+ ) : !data?.data.length ? ( +
No templates found
+ ) : ( + + + + {data.data.map(t => ( + { + onChange(t.id); + setQuery(t.name); + setOpen(false); + }} + > + {t.name} + {t.type} + + ))} + + + + )} + {(data?.total ?? 0) > 20 && ( +
+ Showing 20 of {data!.total} — type to narrow results +
+ )} +
+ )} + + ); +} + // Edit Step Dialog Component interface EditStepDialogProps { step: WorkflowStep & {template?: {id: string; name: string} | null}; @@ -2073,7 +2146,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS // EXIT fields const [exitReason, setExitReason] = useState(String(config?.reason || 'completed')); - const {data: templatesData} = useSWR>('/templates?pageSize=100'); + // templates fetched on-demand by TemplateSearchPicker const {data: workflow} = useSWR(workflowId ? `/workflows/${workflowId}` : null); // Fetch available event names when dialog opens @@ -2359,21 +2432,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS - +

The email template to use for this step

From df18979e5e814f7196e9ef3d84425d30cc5201ad Mon Sep 17 00:00:00 2001 From: Doug Beatty <123423698+wcatbb@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:14:34 -0500 Subject: [PATCH 2/4] extract TemplateSearchPicker to fix workflow template pagination bug --- .../src/components/TemplateSearchPicker.tsx | 104 ++++++++++++++++++ apps/web/src/pages/workflows/[id].tsx | 90 +-------------- 2 files changed, 106 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/components/TemplateSearchPicker.tsx diff --git a/apps/web/src/components/TemplateSearchPicker.tsx b/apps/web/src/components/TemplateSearchPicker.tsx new file mode 100644 index 0000000..6fe9e34 --- /dev/null +++ b/apps/web/src/components/TemplateSearchPicker.tsx @@ -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 | 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) => { + 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>( + 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 ( +
+ { + setOpen(true); + setDebouncedQuery(query); + }} + onBlur={() => setTimeout(() => setOpen(false), 150)} + placeholder="Search templates…" + autoComplete="off" + /> + + {open && ( +
+ {isLoading ? ( +
Searching…
+ ) : !data?.data.length ? ( +
No templates found
+ ) : ( + + + + {data.data.map(t => ( + { + onChange(t.id); + setQuery(t.name); + setOpen(false); + }} + > + {t.name} + {t.type} + + ))} + + + + )} + {(data?.total ?? 0) > 20 && ( +
+ Showing 20 of {data!.total} — type to narrow results +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index b3c38e8..e9039ab 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -56,10 +56,11 @@ import { } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {useCallback, useEffect, useRef, useState} from 'react'; +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'; @@ -1858,93 +1859,6 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo ); } -// TemplateSearchPicker — server-side debounced search, replaces the static { setOpen(true); setDebouncedQuery(query); }} - onBlur={() => setTimeout(() => setOpen(false), 150)} - placeholder="Search templates…" - autoComplete="off" - /> - {open && ( -
- {isLoading ? ( -
Searching…
- ) : !data?.data.length ? ( -
No templates found
- ) : ( - - - - {data.data.map(t => ( - { - onChange(t.id); - setQuery(t.name); - setOpen(false); - }} - > - {t.name} - {t.type} - - ))} - - - - )} - {(data?.total ?? 0) > 20 && ( -
- Showing 20 of {data!.total} — type to narrow results -
- )} -
- )} - - ); -} - // Edit Step Dialog Component interface EditStepDialogProps { step: WorkflowStep & {template?: {id: string; name: string} | null}; From cbde3cce3fcee382dae04e79a9480e67ad896725 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 20 Apr 2026 19:38:20 +0200 Subject: [PATCH 3/4] fix: Sync display name in TemplateSearchPicker when initialName changes --- apps/web/src/components/TemplateSearchPicker.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/TemplateSearchPicker.tsx b/apps/web/src/components/TemplateSearchPicker.tsx index 6fe9e34..b3fc74c 100644 --- a/apps/web/src/components/TemplateSearchPicker.tsx +++ b/apps/web/src/components/TemplateSearchPicker.tsx @@ -2,7 +2,7 @@ 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 {useCallback, useRef, useState} from 'react'; import useSWR from 'swr'; interface TemplateSearchPickerProps { @@ -23,11 +23,13 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea const [debouncedQuery, setDebouncedQuery] = useState(''); const [open, setOpen] = useState(false); const debounceRef = useRef | null>(null); + const prevInitialName = useRef(initialName); - // When the dialog re-opens with an existing selection, sync the display name - useEffect(() => { + // Sync display name when initialName changes (e.g. dialog re-opens with a different selection) + if (initialName !== prevInitialName.current) { + prevInitialName.current = initialName; setQuery(initialName ?? ''); - }, [initialName]); + } const handleInput = useCallback((e: React.ChangeEvent) => { const val = e.target.value; From 3649392b475271198cd61295091e8acf708efa25 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 20 Apr 2026 19:47:39 +0200 Subject: [PATCH 4/4] fix: Sync display name in TemplateSearchPicker when initialName changes --- apps/web/src/components/TemplateSearchPicker.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/TemplateSearchPicker.tsx b/apps/web/src/components/TemplateSearchPicker.tsx index b3fc74c..c283858 100644 --- a/apps/web/src/components/TemplateSearchPicker.tsx +++ b/apps/web/src/components/TemplateSearchPicker.tsx @@ -20,14 +20,13 @@ interface TemplateSearchPickerProps { */ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) { const [query, setQuery] = useState(initialName ?? ''); + const [prevInitialName, setPrevInitialName] = useState(initialName); const [debouncedQuery, setDebouncedQuery] = useState(''); const [open, setOpen] = useState(false); const debounceRef = useRef | null>(null); - const prevInitialName = useRef(initialName); - // Sync display name when initialName changes (e.g. dialog re-opens with a different selection) - if (initialName !== prevInitialName.current) { - prevInitialName.current = initialName; + if (initialName !== prevInitialName) { + setPrevInitialName(initialName); setQuery(initialName ?? ''); }