Skip to content

Commit f92201d

Browse files
committed
webhook multiselect topic (#7818)
<!-- start pr-codex --> ## PR-Codex overview This PR focuses on modifying the handling of `sigHash` to support both single and multiple signature inputs across various components, improving user experience and functionality. ### Detailed summary - Changed `sigHash` from a string to an array in multiple components. - Updated validation and handling of `sigHash` in forms and utilities. - Enhanced UI to reflect multiple signature selections. - Adjusted props and methods to support multi-select functionality in `SignatureSelector` and related components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for selecting multiple signature hashes in webhook filters and selectors. * Enhanced signature selectors to allow both single-select and multi-select modes, with improved handling of custom signatures. * **Improvements** * Updated UI labels, placeholders, and descriptions to reflect multi-selection capabilities and pluralization. * Review and summary steps now display multiple selected signatures with appropriate formatting. * Webhook payloads and validation schemas now support arrays of signature hashes for improved flexibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 45ba811 commit f92201d

8 files changed

Lines changed: 254 additions & 90 deletions

File tree

apps/dashboard/src/@/components/blocks/SignatureSelector.tsx

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ interface SignatureOption {
1010

1111
interface SignatureSelectorProps {
1212
options: SignatureOption[];
13-
value: string;
14-
onChange: (val: string) => void;
13+
value: string | string[];
14+
onChange: (val: string | string[]) => void;
1515
setAbi?: (abi: string) => void;
1616
placeholder?: string;
1717
disabled?: boolean;
1818
secondaryTextFormatter?: (sig: SignatureOption) => string;
1919
className?: string;
20+
multiSelect?: boolean;
2021
}
2122

2223
export function SignatureSelector({
@@ -28,6 +29,7 @@ export function SignatureSelector({
2829
disabled,
2930
secondaryTextFormatter,
3031
className,
32+
multiSelect = false,
3133
}: SignatureSelectorProps) {
3234
const [searchValue, setSearchValue] = useState("");
3335
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,38 +44,96 @@ export function SignatureSelector({
4244
}));
4345
}, [options, secondaryTextFormatter]);
4446

45-
// Check if the current value is a custom value (not in options)
46-
const isCustomValue = value && !options.some((opt) => opt.value === value);
47+
// Handle both single and multi-select values
48+
const currentValues = useMemo((): string[] => {
49+
if (multiSelect) {
50+
if (Array.isArray(value)) {
51+
return value.filter(
52+
(val): val is string =>
53+
val !== undefined && val !== null && val !== "",
54+
);
55+
} else {
56+
return value ? [value] : [];
57+
}
58+
} else {
59+
if (Array.isArray(value)) {
60+
return value.length > 0 && value[0] ? [value[0]] : [];
61+
} else {
62+
return value ? [value] : [];
63+
}
64+
}
65+
}, [value, multiSelect]);
66+
67+
// Check if the current values include custom values (not in options)
68+
const customValues = useMemo((): string[] => {
69+
return currentValues.filter(
70+
(val): val is string =>
71+
val !== undefined &&
72+
val !== null &&
73+
val !== "" &&
74+
!options.some((opt) => opt.value === val),
75+
);
76+
}, [currentValues, options]);
4777

48-
// Add the custom value as an option if needed
78+
// Add the custom values as options if needed
4979
const allOptions = useMemo(() => {
50-
if (isCustomValue && value) {
51-
return [...formattedOptions, { label: value, value }];
52-
}
53-
return formattedOptions;
54-
}, [formattedOptions, isCustomValue, value]);
80+
const customOptions = customValues.map((val) => ({
81+
label: val,
82+
value: val,
83+
}));
84+
return [...formattedOptions, ...customOptions];
85+
}, [formattedOptions, customValues]);
5586

56-
// Single-select MultiSelect wrapper
87+
// Multi-select or single-select MultiSelect wrapper
5788
const handleSelectedValuesChange = useCallback(
5889
(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 || "");
90+
if (multiSelect) {
91+
// Multi-select behavior
92+
onChange(selected);
93+
// For multi-select, we'll use the ABI from the first selected option that has one
94+
const firstOptionWithAbi = selected.find((selectedValue) => {
95+
const found = options.find((opt) => opt.value === selectedValue);
96+
return found?.abi;
97+
});
98+
if (setAbi && firstOptionWithAbi) {
99+
const found = options.find((opt) => opt.value === firstOptionWithAbi);
100+
setAbi(found?.abi || "");
101+
}
102+
} else {
103+
// Single-select behavior (maintain backward compatibility)
104+
const selectedValue =
105+
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
106+
onChange(selectedValue);
107+
const found = options.find((opt) => opt.value === selectedValue);
108+
if (setAbi) {
109+
setAbi(found?.abi || "");
110+
}
66111
}
67112
setSearchValue("");
68113
},
69-
[onChange, setAbi, options],
114+
[onChange, setAbi, options, multiSelect],
70115
);
71116

72117
// Handle custom value entry
73118
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
74119
if (event.key === "Enter" && searchValue.trim()) {
75120
if (!options.some((opt) => opt.value === searchValue.trim())) {
76-
onChange(searchValue.trim());
121+
if (multiSelect) {
122+
// Add to existing values for multi-select
123+
const currentArray = Array.isArray(value)
124+
? value
125+
: value
126+
? [value]
127+
: [];
128+
const filteredArray = currentArray.filter(
129+
(val): val is string => val !== undefined && val !== null,
130+
);
131+
const newValues = [...filteredArray, searchValue.trim()];
132+
onChange(newValues);
133+
} else {
134+
// Replace value for single-select
135+
onChange(searchValue.trim());
136+
}
77137
if (setAbi) setAbi("");
78138
setSearchValue("");
79139
// Optionally blur input
@@ -106,7 +166,7 @@ export function SignatureSelector({
106166
customSearchInput={customSearchInput}
107167
customTrigger={null}
108168
disabled={disabled}
109-
maxCount={1}
169+
maxCount={multiSelect ? 100 : 1}
110170
onSelectedValuesChange={handleSelectedValuesChange}
111171
options={allOptions}
112172
overrideSearchFn={(option, searchTerm) =>
@@ -116,11 +176,13 @@ export function SignatureSelector({
116176
placeholder={placeholder}
117177
renderOption={(option) => <span>{option.label}</span>}
118178
searchPlaceholder={placeholder}
119-
selectedValues={value ? [value] : []}
179+
selectedValues={currentValues}
120180
/>
121-
{isCustomValue && (
181+
{customValues.length > 0 && (
122182
<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.
183+
{multiSelect
184+
? `You entered ${customValues.length} custom signature${customValues.length > 1 ? "s" : ""}. Please provide the ABI below.`
185+
: "You entered a custom signature. Please provide the ABI below."}
124186
</div>
125187
)}
126188
</div>

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
6464
popoverContentClassName,
6565
showSelectedValuesInModal = false,
6666
customSearchInput,
67+
customTrigger,
6768
...props
6869
},
6970
ref,
@@ -144,13 +145,18 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
144145
// scroll to top when options change
145146
const popoverElRef = useRef<HTMLDivElement>(null);
146147

148+
// Filter out customTrigger from props to avoid passing it to Button
149+
const buttonProps = Object.fromEntries(
150+
Object.entries(props).filter(([key]) => key !== "customTrigger"),
151+
) as React.ButtonHTMLAttributes<HTMLButtonElement>;
152+
147153
return (
148154
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
149155
<PopoverTrigger asChild>
150-
{props.customTrigger || (
156+
{customTrigger || (
151157
<Button
152158
ref={ref}
153-
{...props}
159+
{...buttonProps}
154160
className={cn(
155161
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
156162
className,

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/CreateWebhookModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function CreateContractWebhookButton({
6565
inputAbi: [],
6666
name: "",
6767
secret: "",
68-
sigHash: "",
68+
sigHash: [],
6969
sigHashAbi: "",
7070
toAddresses: "",
7171
webhookUrl: "",

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/FilterDetailsStep.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,16 @@ export function FilterDetailsStep({
7474
const knownFunctionSignatures = functionSignatures.map(
7575
(sig) => sig.signature,
7676
);
77+
78+
// Handle both single and multiple signatures for custom signature detection
79+
const sigHashes = Array.isArray(sigHash) ? sigHash : sigHash ? [sigHash] : [];
7780
const isCustomSignature =
7881
(watchFilterType === "event" &&
79-
sigHash &&
80-
!knownEventSignatures.includes(sigHash)) ||
82+
sigHashes.some((hash) => hash && !knownEventSignatures.includes(hash))) ||
8183
(watchFilterType === "transaction" &&
82-
sigHash &&
83-
!knownFunctionSignatures.includes(sigHash));
84+
sigHashes.some(
85+
(hash) => hash && !knownFunctionSignatures.includes(hash),
86+
));
8487

8588
return (
8689
<>
@@ -140,7 +143,7 @@ export function FilterDetailsStep({
140143
</div>
141144
<FormControl>
142145
<div className="space-y-2">
143-
<Input placeholder="0x1234..." {...field} />
146+
<Input placeholder="0x1234...,0xabcd..." {...field} />
144147

145148
{/* ABI fetch status */}
146149
<div className="mt-2 flex items-center justify-between">
@@ -300,13 +303,13 @@ export function FilterDetailsStep({
300303
<div className="flex items-center justify-between text-xs">
301304
<FormLabel>
302305
{watchFilterType === "event"
303-
? "Event Signature (optional)"
304-
: "Function Signature (optional)"}
306+
? "Event Signatures (optional)"
307+
: "Function Signatures (optional)"}
305308
</FormLabel>
306309
<p className="text-muted-foreground">
307310
{watchFilterType === "event"
308-
? "Select an event to monitor"
309-
: "Select a function to monitor"}
311+
? "Select events to monitor"
312+
: "Select functions to monitor"}
310313
</p>
311314
</div>
312315
<FormControl>
@@ -315,11 +318,16 @@ export function FilterDetailsStep({
315318
eventSignatures.length > 0 ? (
316319
<SignatureSelector
317320
className="block w-full max-w-90 overflow-hidden text-ellipsis"
321+
multiSelect={true}
318322
onChange={(val) => {
319323
field.onChange(val);
320324
// If custom signature, clear ABI field
321325
const known = eventSignatures.map((sig) => sig.signature);
322-
if (val && !known.includes(val)) {
326+
const values = Array.isArray(val) ? val : [val];
327+
const hasCustomSignature = values.some(
328+
(v) => v && !known.includes(v),
329+
);
330+
if (hasCustomSignature) {
323331
form.setValue("abi", "");
324332
}
325333
}}
@@ -328,21 +336,26 @@ export function FilterDetailsStep({
328336
label: truncateMiddle(sig.name, 30, 15),
329337
value: sig.signature,
330338
}))}
331-
placeholder="Select or enter an event signature"
339+
placeholder="Select or enter event signatures"
332340
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
333-
value={field.value || ""}
341+
value={field.value || []}
334342
/>
335343
) : watchFilterType === "transaction" &&
336344
Object.keys(fetchedTxAbis).length > 0 &&
337345
functionSignatures.length > 0 ? (
338346
<SignatureSelector
347+
multiSelect={true}
339348
onChange={(val) => {
340349
field.onChange(val);
341350
// If custom signature, clear ABI field
342351
const known = functionSignatures.map(
343352
(sig) => sig.signature,
344353
);
345-
if (val && !known.includes(val)) {
354+
const values = Array.isArray(val) ? val : [val];
355+
const hasCustomSignature = values.some(
356+
(v) => v && !known.includes(v),
357+
);
358+
if (hasCustomSignature) {
346359
form.setValue("abi", "");
347360
}
348361
}}
@@ -351,9 +364,9 @@ export function FilterDetailsStep({
351364
label: truncateMiddle(sig.name, 30, 15),
352365
value: sig.signature,
353366
}))}
354-
placeholder="Select or enter a function signature"
367+
placeholder="Select or enter function signatures"
355368
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
356-
value={field.value || ""}
369+
value={field.value || []}
357370
/>
358371
) : (
359372
<Input
@@ -367,7 +380,11 @@ export function FilterDetailsStep({
367380
? "Fetching event signatures..."
368381
: "Fetching function signatures..."
369382
}
370-
value={field.value}
383+
value={
384+
Array.isArray(field.value)
385+
? field.value.join(", ")
386+
: field.value || ""
387+
}
371388
/>
372389
)}
373390
</FormControl>

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/ReviewStep.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,27 @@ export default function ReviewStep({
194194

195195
<li className="flex justify-between">
196196
<span className="text-muted-foreground text-sm">
197-
Signature Hash:
197+
Signature Hash
198+
{Array.isArray(form.watch("sigHash")) &&
199+
(form.watch("sigHash")?.length || 0) > 1
200+
? "es"
201+
: ""}
202+
:
198203
</span>
199204
<span className="font-medium text-sm">
200205
{(() => {
201206
const sigHash = form.watch("sigHash");
202-
return sigHash ? truncateMiddle(sigHash, 10, 6) : "None";
207+
if (!sigHash) return "None";
208+
209+
if (Array.isArray(sigHash)) {
210+
if (sigHash.length === 0) return "None";
211+
if (sigHash.length === 1) {
212+
return truncateMiddle(sigHash[0] || "", 10, 6);
213+
}
214+
return `${sigHash.length} signature${sigHash.length > 1 ? "s" : ""} selected`;
215+
} else {
216+
return truncateMiddle(sigHash, 10, 6);
217+
}
203218
})()}
204219
</span>
205220
</li>

0 commit comments

Comments
 (0)