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] 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};