Skip to content

Commit 3aa58e7

Browse files
author
Amine Afia
committed
Add SignatureSelector component for custom signature input in webhooks
1 parent bb09e20 commit 3aa58e7

File tree

4 files changed

+320
-210
lines changed

4 files changed

+320
-210
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { cn } from "@/lib/utils";
2+
import { useCallback, useMemo, useRef, useState } from "react";
3+
import { MultiSelect } from "./multi-select";
4+
5+
interface SignatureOption {
6+
label: string;
7+
value: string;
8+
abi?: string;
9+
}
10+
11+
interface SignatureSelectorProps {
12+
options: SignatureOption[];
13+
value: string;
14+
onChange: (val: string) => void;
15+
setAbi?: (abi: string) => void;
16+
placeholder?: string;
17+
disabled?: boolean;
18+
secondaryTextFormatter?: (sig: SignatureOption) => string;
19+
className?: string;
20+
}
21+
22+
export function SignatureSelector({
23+
options,
24+
value,
25+
onChange,
26+
setAbi,
27+
placeholder = "Select or enter a signature",
28+
disabled,
29+
secondaryTextFormatter,
30+
className,
31+
}: SignatureSelectorProps) {
32+
const [searchValue, setSearchValue] = useState("");
33+
const inputRef = useRef<HTMLInputElement>(null);
34+
35+
// Memoize options with formatted secondary text if provided
36+
const formattedOptions = useMemo(() => {
37+
return options.map((opt) => ({
38+
...opt,
39+
label: secondaryTextFormatter
40+
? `${opt.label}${secondaryTextFormatter(opt)}`
41+
: opt.label,
42+
}));
43+
}, [options, secondaryTextFormatter]);
44+
45+
// Check if the current value is a custom value (not in options)
46+
const isCustomValue = value && !options.some((opt) => opt.value === value);
47+
48+
// Add the custom value as an option if needed
49+
const allOptions = useMemo(() => {
50+
if (isCustomValue && value) {
51+
return [...formattedOptions, { label: value, value }];
52+
}
53+
return formattedOptions;
54+
}, [formattedOptions, isCustomValue, value]);
55+
56+
// Single-select MultiSelect wrapper
57+
const handleSelectedValuesChange = useCallback(
58+
(selected: string[]) => {
59+
// Always use the last selected value for single-select behavior
60+
const selectedValue =
61+
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
62+
onChange(selectedValue);
63+
const found = options.find((opt) => opt.value === selectedValue);
64+
if (setAbi) {
65+
setAbi(found?.abi || "");
66+
}
67+
setSearchValue("");
68+
},
69+
[onChange, setAbi, options],
70+
);
71+
72+
// Handle custom value entry
73+
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
74+
if (event.key === "Enter" && searchValue.trim()) {
75+
if (!options.some((opt) => opt.value === searchValue.trim())) {
76+
onChange(searchValue.trim());
77+
if (setAbi) setAbi("");
78+
setSearchValue("");
79+
// Optionally blur input
80+
inputRef.current?.blur();
81+
}
82+
}
83+
};
84+
85+
// Custom render for MultiSelect's search input
86+
const customSearchInput = (
87+
<input
88+
ref={inputRef}
89+
type="text"
90+
className={cn(
91+
"w-full border-0 border-border border-b bg-transparent py-4 pr-2 pl-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
92+
disabled && "cursor-not-allowed opacity-50",
93+
)}
94+
placeholder={placeholder}
95+
value={searchValue}
96+
onChange={(e) => setSearchValue(e.target.value)}
97+
onKeyDown={handleInputKeyDown}
98+
disabled={disabled}
99+
autoComplete="off"
100+
/>
101+
);
102+
103+
return (
104+
<div className={className}>
105+
<MultiSelect
106+
options={allOptions}
107+
selectedValues={value ? [value] : []}
108+
onSelectedValuesChange={handleSelectedValuesChange}
109+
placeholder={placeholder}
110+
maxCount={1}
111+
disabled={disabled}
112+
searchPlaceholder={placeholder}
113+
customTrigger={null}
114+
renderOption={(option) => <span>{option.label}</span>}
115+
overrideSearchFn={(option, searchTerm) =>
116+
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
117+
option.value.toLowerCase().includes(searchTerm.toLowerCase())
118+
}
119+
customSearchInput={customSearchInput}
120+
/>
121+
{isCustomValue && (
122+
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
123+
You entered a custom signature. Please provide the ABI below.
124+
</div>
125+
)}
126+
</div>
127+
);
128+
}

apps/dashboard/src/@/components/blocks/multi-select.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ interface MultiSelectProps
5353
align?: "center" | "start" | "end";
5454
side?: "left" | "right" | "top" | "bottom";
5555
showSelectedValuesInModal?: boolean;
56+
customSearchInput?: React.ReactNode;
5657
}
5758

5859
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
@@ -69,6 +70,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
6970
searchPlaceholder,
7071
popoverContentClassName,
7172
showSelectedValuesInModal = false,
73+
customSearchInput,
7274
...props
7375
},
7476
ref,
@@ -233,15 +235,19 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
233235
>
234236
{/* Search */}
235237
<div className="relative">
236-
<Input
237-
placeholder={searchPlaceholder || "Search"}
238-
value={searchValue}
239-
// do not focus on the input when the popover opens to avoid opening the keyboard
240-
tabIndex={-1}
241-
onChange={(e) => setSearchValue(e.target.value)}
242-
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
243-
onKeyDown={handleInputKeyDown}
244-
/>
238+
{customSearchInput ? (
239+
customSearchInput
240+
) : (
241+
<Input
242+
placeholder={searchPlaceholder || "Search"}
243+
value={searchValue}
244+
// do not focus on the input when the popover opens to avoid opening the keyboard
245+
tabIndex={-1}
246+
onChange={(e) => setSearchValue(e.target.value)}
247+
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
248+
onKeyDown={handleInputKeyDown}
249+
/>
250+
)}
245251
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
246252
</div>
247253

0 commit comments

Comments
 (0)