Skip to content

Commit 286f8e1

Browse files
committed
feat: improve webhook integration (customized body and headers)
1 parent f8f470a commit 286f8e1

20 files changed

Lines changed: 1994 additions & 91 deletions

File tree

apps/api/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ COPY packages/payments/package.json packages/payments/
4141
COPY packages/constants/package.json packages/constants/
4242
COPY packages/validation/package.json packages/validation/
4343
COPY packages/integrations/package.json packages/integrations/
44+
COPY packages/js-runtime/package.json packages/js-runtime/
4445
COPY patches ./patches
4546

4647
# BUILD
@@ -108,6 +109,7 @@ COPY --from=build /app/packages/payments ./packages/payments
108109
COPY --from=build /app/packages/constants ./packages/constants
109110
COPY --from=build /app/packages/validation ./packages/validation
110111
COPY --from=build /app/packages/integrations ./packages/integrations
112+
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
111113
COPY --from=build /app/tooling/typescript ./tooling/typescript
112114
RUN pnpm db:codegen
113115

apps/start/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,16 @@
8484
"@types/d3": "^7.4.3",
8585
"ai": "^4.2.10",
8686
"bind-event-listener": "^3.0.0",
87+
"@codemirror/commands": "^6.7.0",
88+
"@codemirror/lang-javascript": "^6.2.0",
89+
"@codemirror/lang-json": "^6.0.1",
90+
"@codemirror/state": "^6.4.0",
91+
"@codemirror/theme-one-dark": "^6.1.3",
92+
"@codemirror/view": "^6.35.0",
8793
"class-variance-authority": "^0.7.1",
8894
"clsx": "^2.1.1",
8995
"cmdk": "^0.2.1",
96+
"codemirror": "^6.0.1",
9097
"d3": "^7.8.5",
9198
"date-fns": "^3.3.1",
9299
"debounce": "^2.2.0",

apps/start/src/components/integrations/forms/webhook-integration.tsx

Lines changed: 265 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,53 @@
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';
23
import { Button } from '@/components/ui/button';
4+
import { Combobox } from '@/components/ui/combobox';
5+
import { Input } from '@/components/ui/input';
36
import { useAppParams } from '@/hooks/use-app-params';
47
import { useTRPC } from '@/integrations/trpc/react';
58
import type { RouterOutputs } from '@/trpc/client';
69
import { zodResolver } from '@hookform/resolvers/zod';
710
import { zCreateWebhookIntegration } from '@openpanel/validation';
811
import { useMutation } from '@tanstack/react-query';
12+
import { PlusIcon, TrashIcon } from 'lucide-react';
913
import { path, mergeDeepRight } from 'ramda';
14+
import { useEffect } from 'react';
15+
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
1016
import { useForm } from 'react-hook-form';
1117
import { toast } from 'sonner';
1218
import type { z } from 'zod';
1319

1420
type 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+
1651
export 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

Comments
 (0)