1- import { InputWithLabel } from '@/components/forms/input-with-label' ;
1+ import { InputWithLabel , WithLabel } from '@/components/forms/input-with-label' ;
2+ import { JsonEditor } from '@/components/json-editor' ;
23import { Button } from '@/components/ui/button' ;
4+ import { Combobox } from '@/components/ui/combobox' ;
5+ import { Input } from '@/components/ui/input' ;
36import { useAppParams } from '@/hooks/use-app-params' ;
47import { useTRPC } from '@/integrations/trpc/react' ;
58import type { RouterOutputs } from '@/trpc/client' ;
69import { zodResolver } from '@hookform/resolvers/zod' ;
710import { zCreateWebhookIntegration } from '@openpanel/validation' ;
811import { useMutation } from '@tanstack/react-query' ;
12+ import { PlusIcon , TrashIcon } from 'lucide-react' ;
913import { path , mergeDeepRight } from 'ramda' ;
14+ import { useEffect } from 'react' ;
15+ import { Controller , useFieldArray , useWatch } from 'react-hook-form' ;
1016import { useForm } from 'react-hook-form' ;
1117import { toast } from 'sonner' ;
1218import type { z } from 'zod' ;
1319
1420type IForm = z . infer < typeof zCreateWebhookIntegration > ;
1521
22+ const DEFAULT_TRANSFORMER = `(payload) => {
23+ return payload;
24+ }` ;
25+
26+ // Convert Record<string, string> to array format for form
27+ function headersToArray (
28+ headers : Record < string , string > | undefined ,
29+ ) : { key : string ; value : string } [ ] {
30+ if ( ! headers || Object . keys ( headers ) . length === 0 ) {
31+ return [ ] ;
32+ }
33+ return Object . entries ( headers ) . map ( ( [ key , value ] ) => ( { key, value } ) ) ;
34+ }
35+
36+ // Convert array format back to Record<string, string> for API
37+ function headersToRecord (
38+ headers : { key : string ; value : string } [ ] ,
39+ ) : Record < string , string > {
40+ return headers . reduce (
41+ ( acc , { key, value } ) => {
42+ if ( key . trim ( ) ) {
43+ acc [ key . trim ( ) ] = value ;
44+ }
45+ return acc ;
46+ } ,
47+ { } as Record < string , string > ,
48+ ) ;
49+ }
50+
1651export function WebhookIntegrationForm ( {
1752 defaultValues,
1853 onSuccess,
@@ -21,6 +56,13 @@ export function WebhookIntegrationForm({
2156 onSuccess : ( ) => void ;
2257} ) {
2358 const { organizationId } = useAppParams ( ) ;
59+
60+ // Convert headers from Record to array format for form UI
61+ const defaultHeaders =
62+ defaultValues ?. config && 'headers' in defaultValues . config
63+ ? headersToArray ( defaultValues . config . headers )
64+ : [ ] ;
65+
2466 const form = useForm < IForm > ( {
2567 defaultValues : mergeDeepRight (
2668 {
@@ -30,18 +72,68 @@ export function WebhookIntegrationForm({
3072 type : 'webhook' as const ,
3173 url : '' ,
3274 headers : { } ,
75+ mode : 'message' as const ,
76+ javascriptTemplate : undefined ,
3377 } ,
3478 } ,
3579 defaultValues ?? { } ,
3680 ) ,
3781 resolver : zodResolver ( zCreateWebhookIntegration ) ,
3882 } ) ;
83+
84+ // Use a separate form for headers array to work with useFieldArray
85+ const headersForm = useForm < { headers : { key : string ; value : string } [ ] } > ( {
86+ defaultValues : {
87+ headers : defaultHeaders ,
88+ } ,
89+ } ) ;
90+
91+ const headersArray = useFieldArray ( {
92+ control : headersForm . control ,
93+ name : 'headers' ,
94+ } ) ;
95+
96+ // Watch headers array and sync to main form
97+ const watchedHeaders = useWatch ( {
98+ control : headersForm . control ,
99+ name : 'headers' ,
100+ } ) ;
101+
102+ // Sync headers array changes back to main form
103+ useEffect ( ( ) => {
104+ if ( watchedHeaders ) {
105+ const validHeaders = watchedHeaders . filter (
106+ ( h ) : h is { key : string ; value : string } =>
107+ h !== undefined &&
108+ typeof h . key === 'string' &&
109+ typeof h . value === 'string' ,
110+ ) ;
111+ form . setValue ( 'config.headers' , headersToRecord ( validHeaders ) , {
112+ shouldValidate : false ,
113+ } ) ;
114+ }
115+ } , [ watchedHeaders , form ] ) ;
116+
117+ const mode = form . watch ( 'config.mode' ) ;
39118 const trpc = useTRPC ( ) ;
40119 const mutation = useMutation (
41120 trpc . integration . createOrUpdate . mutationOptions ( {
42121 onSuccess,
43- onError ( ) {
44- toast . error ( 'Failed to create integration' ) ;
122+ onError ( error ) {
123+ // Handle validation errors from tRPC
124+ if ( error . data ?. code === 'BAD_REQUEST' ) {
125+ const errorMessage = error . message || 'Invalid JavaScript template' ;
126+ toast . error ( errorMessage ) ;
127+ // Set form error if it's a JavaScript template error
128+ if ( errorMessage . includes ( 'JavaScript template' ) ) {
129+ form . setError ( 'config.javascriptTemplate' , {
130+ type : 'manual' ,
131+ message : errorMessage ,
132+ } ) ;
133+ }
134+ } else {
135+ toast . error ( 'Failed to create integration' ) ;
136+ }
45137 } ,
46138 } ) ,
47139 ) ;
@@ -70,7 +162,176 @@ export function WebhookIntegrationForm({
70162 { ...form . register ( 'config.url' ) }
71163 error = { path ( [ 'config' , 'url' , 'message' ] , form . formState . errors ) }
72164 />
73- < Button type = "submit" > Create</ Button >
165+
166+ < WithLabel
167+ label = "Headers"
168+ info = "Add custom HTTP headers to include with webhook requests"
169+ >
170+ < div className = "col gap-2" >
171+ { headersArray . fields . map ( ( field , index ) => (
172+ < div key = { field . id } className = "row gap-2" >
173+ < Input
174+ placeholder = "Header Name"
175+ { ...headersForm . register ( `headers.${ index } .key` ) }
176+ className = "flex-1"
177+ />
178+ < Input
179+ placeholder = "Header Value"
180+ { ...headersForm . register ( `headers.${ index } .value` ) }
181+ className = "flex-1"
182+ />
183+ < Button
184+ type = "button"
185+ variant = "ghost"
186+ size = "icon"
187+ onClick = { ( ) => headersArray . remove ( index ) }
188+ className = "text-destructive"
189+ >
190+ < TrashIcon className = "h-4 w-4" />
191+ </ Button >
192+ </ div >
193+ ) ) }
194+ < Button
195+ type = "button"
196+ variant = "outline"
197+ onClick = { ( ) => headersArray . append ( { key : '' , value : '' } ) }
198+ className = "self-start"
199+ icon = { PlusIcon }
200+ >
201+ Add Header
202+ </ Button >
203+ </ div >
204+ </ WithLabel >
205+
206+ < Controller
207+ control = { form . control }
208+ name = "config.mode"
209+ render = { ( { field } ) => (
210+ < WithLabel
211+ label = "Payload Format"
212+ info = "Choose how to format the webhook payload"
213+ >
214+ < Combobox
215+ { ...field }
216+ className = "w-full"
217+ placeholder = "Select format"
218+ items = { [
219+ {
220+ label : 'Message' ,
221+ value : 'message' as const ,
222+ } ,
223+ {
224+ label : 'JavaScript' ,
225+ value : 'javascript' as const ,
226+ } ,
227+ ] }
228+ value = { field . value ?? 'message' }
229+ onChange = { field . onChange }
230+ />
231+ </ WithLabel >
232+ ) }
233+ />
234+
235+ { mode === 'javascript' && (
236+ < Controller
237+ control = { form . control }
238+ name = "config.javascriptTemplate"
239+ render = { ( { field } ) => (
240+ < WithLabel
241+ label = "JavaScript Transform"
242+ info = {
243+ < div className = "prose dark:prose-invert max-w-none" >
244+ < p >
245+ Write a JavaScript function that transforms the event
246+ payload. The function receives < code > payload</ code > as a
247+ parameter and should return an object.
248+ </ p >
249+ < p className = "text-sm font-semibold mt-2" >
250+ Available in payload:
251+ </ p >
252+ < ul className = "text-sm" >
253+ < li >
254+ < code > payload.name</ code > - Event name
255+ </ li >
256+ < li >
257+ < code > payload.profileId</ code > - User profile ID
258+ </ li >
259+ < li >
260+ < code > payload.properties</ code > - Full properties object
261+ </ li >
262+ < li >
263+ < code > payload.properties.your.property</ code > - Nested
264+ property value
265+ </ li >
266+ < li >
267+ < code > payload.profile.firstName</ code > - Profile property
268+ </ li >
269+ < li >
270+ < div className = "flex gap-x-2 flex-wrap mt-1" >
271+ < code > country</ code >
272+ < code > city</ code >
273+ < code > device</ code >
274+ < code > os</ code >
275+ < code > browser</ code >
276+ < code > path</ code >
277+ < code > createdAt</ code >
278+ and more...
279+ </ div >
280+ </ li >
281+ </ ul >
282+ < p className = "text-sm font-semibold mt-2" >
283+ Available helpers:
284+ </ p >
285+ < ul className = "text-sm" >
286+ < li >
287+ < code > Math</ code > , < code > Date</ code > , < code > JSON</ code > ,{ ' ' }
288+ < code > Array</ code > , < code > String</ code > ,{ ' ' }
289+ < code > Object</ code >
290+ </ li >
291+ </ ul >
292+ < p className = "text-sm mt-2" >
293+ < strong > Example:</ strong >
294+ </ p >
295+ < pre className = "text-xs bg-muted p-2 rounded mt-1 overflow-x-auto" >
296+ { `(payload) => ({
297+ event: payload.name,
298+ user: payload.profileId,
299+ data: payload.properties,
300+ timestamp: new Date(payload.createdAt).toISOString(),
301+ location: \`\${payload.city}, \${payload.country}\`
302+ })` }
303+ </ pre >
304+ < p className = "text-sm mt-2 text-yellow-600 dark:text-yellow-400" >
305+ < strong > Security:</ strong > Network calls, file system
306+ access, and other dangerous operations are blocked.
307+ </ p >
308+ </ div >
309+ }
310+ >
311+ < JsonEditor
312+ value = { field . value ?? DEFAULT_TRANSFORMER }
313+ onChange = { ( value ) => {
314+ field . onChange ( value ) ;
315+ // Clear error when user starts typing
316+ if ( form . formState . errors . config ?. javascriptTemplate ) {
317+ form . clearErrors ( 'config.javascriptTemplate' ) ;
318+ }
319+ } }
320+ placeholder = { DEFAULT_TRANSFORMER }
321+ minHeight = "300px"
322+ language = "javascript"
323+ />
324+ { form . formState . errors . config ?. javascriptTemplate && (
325+ < p className = "mt-1 text-sm text-destructive" >
326+ { form . formState . errors . config . javascriptTemplate . message }
327+ </ p >
328+ ) }
329+ </ WithLabel >
330+ ) }
331+ />
332+ ) }
333+
334+ < Button type = "submit" > { defaultValues ?. id ? 'Update' : 'Create' } </ Button >
74335 </ form >
75336 ) ;
76337}
0 commit comments