extract TemplateSearchPicker to fix workflow template pagination bug
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,10 +56,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useCallback, useEffect, useRef, 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 {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||||
|
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
|
||||||
import {ReactFlowProvider} from '@xyflow/react';
|
import {ReactFlowProvider} from '@xyflow/react';
|
||||||
import {WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -1858,93 +1859,6 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TemplateSearchPicker — server-side debounced search, replaces the static <Select> for templates
|
|
||||||
interface TemplateSearchPickerProps {
|
|
||||||
value: string; // selected template ID
|
|
||||||
initialName?: string; // display name for the currently-selected template (pre-fill input)
|
|
||||||
onChange: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Sync display name when dialog re-opens with an existing selection
|
|
||||||
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},
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedName = value
|
|
||||||
? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={open ? query : selectedName}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit Step Dialog Component
|
// Edit Step Dialog Component
|
||||||
interface EditStepDialogProps {
|
interface EditStepDialogProps {
|
||||||
step: WorkflowStep & {template?: {id: string; name: string} | null};
|
step: WorkflowStep & {template?: {id: string; name: string} | null};
|
||||||
|
|||||||
Reference in New Issue
Block a user