mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 08:09:46 -06:00
feat: webhooks UI
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
} from "@formbricks/ui";
|
||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { createWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/testEndpoint";
|
||||
|
||||
interface AddWebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AddWebhookModal({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) {
|
||||
const router = useRouter();
|
||||
const { handleSubmit, reset } = useForm();
|
||||
|
||||
const submitWebhook = async (): Promise<void> => {
|
||||
if (testEndpointInput === undefined || testEndpointInput === "") {
|
||||
toast.error("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTriggers.length === 0) {
|
||||
toast.error("Please select at least one trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData: TWebhookInput = {
|
||||
url: testEndpointInput,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
try {
|
||||
await createWebhook(environmentId, updatedData);
|
||||
router.refresh();
|
||||
reset();
|
||||
setOpen(false);
|
||||
toast.success("Webhook added successfully.");
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
const renderSelectedSurveysText = () => {
|
||||
if (selectedSurveys.length === 0) {
|
||||
return <p className="text-slate-400">Select Surveys for this webhook</p>;
|
||||
} else {
|
||||
const selectedSurveyNames = selectedSurveys.map((surveyId) => {
|
||||
const survey = surveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
};
|
||||
const [testEndpointInput, setTestEndpointInput] = useState("");
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId) => {
|
||||
setSelectedSurveys((prevSelectedSurveys) => {
|
||||
if (prevSelectedSurveys.includes(surveyId)) {
|
||||
return prevSelectedSurveys.filter((id) => id !== surveyId);
|
||||
} else {
|
||||
return [...prevSelectedSurveys, surveyId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue) => {
|
||||
setSelectedTriggers((prevValues) => {
|
||||
if (prevValues.includes(selectedValue)) {
|
||||
return prevValues.filter((value) => value !== selectedValue);
|
||||
} else {
|
||||
return [...prevValues, selectedValue];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestEndpoint = async () => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<CursorArrowRaysIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">Add Webhook</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Send alerts to your own custom endpoints when an action is striggered in a survey.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitWebhook)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="col-span-1">
|
||||
<Label>URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="text"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint();
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Triggers</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseCreated"
|
||||
checked={selectedTriggers.includes("responseCreated")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseCreated");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Created</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseUpdated"
|
||||
checked={selectedTriggers.includes("responseUpdated")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseUpdated");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Updated</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseFinished"
|
||||
checked={selectedTriggers.includes("responseFinished")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseFinished");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Finished</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surveys to enable</Label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ">
|
||||
{renderSelectedSurveysText()}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-full bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{surveys.map((survey) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={survey.id}
|
||||
checked={selectedSurveys.includes(survey.id)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={() => handleSelectedSurveyChange(survey.id)}>
|
||||
{survey.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit">
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Label } from "@formbricks/ui";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface ActivityTabProps {
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
const convertTriggerIdToName = (triggerId: string): string => {
|
||||
switch (triggerId) {
|
||||
case "responseCreated":
|
||||
return "Response Created";
|
||||
case "responseUpdated":
|
||||
return "Response Updated";
|
||||
case "responseFinished":
|
||||
return "Response Finished";
|
||||
default:
|
||||
return triggerId;
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookActivityTab({ webhook, surveys }: ActivityTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
<div className="col-span-2 space-y-4 pr-6">
|
||||
<div>
|
||||
<Label className="text-slate-500">URL</Label>
|
||||
<p className="text-sm text-slate-900">{webhook.url}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Surveys</Label>
|
||||
{webhook.surveyIds.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{webhook.surveyIds
|
||||
.map((surveyId) => surveys.find((survey) => survey.id === surveyId)?.name)
|
||||
.map((surveyName) => (
|
||||
<p key={surveyName} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">Triggers</Label>
|
||||
{webhook.triggers.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{webhook.triggers.map((triggerId) => (
|
||||
<p key={triggerId} className="text-sm text-slate-900">
|
||||
{convertTriggerIdToName(triggerId)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">Created on</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.createdAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className=" text-xs font-normal text-slate-500">Last updated</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import ModalWithTabs from "@/components/shared/ModalWithTabs";
|
||||
import { CodeBracketIcon } from "@heroicons/react/24/solid";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import WebhookActivityTab from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookActivityTab";
|
||||
import WebhookSettingsTab from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookSettingsTab";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface WebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
export default function WebhookModal({ environmentId, open, setOpen, webhook, surveys }: WebhookModalProps) {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Activity",
|
||||
children: <WebhookActivityTab webhook={webhook} surveys={surveys} />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
children: (
|
||||
<WebhookSettingsTab
|
||||
environmentId={environmentId}
|
||||
webhook={webhook}
|
||||
surveys={surveys}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={<CodeBracketIcon />}
|
||||
label={webhook.url}
|
||||
description={""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
return <p className="text-slate-400">No Surveys</p>;
|
||||
} else {
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: TWebhook) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
return "Response Created";
|
||||
} else if (trigger === "responseUpdated") {
|
||||
return "Response Updated";
|
||||
} else if (trigger === "responseFinished") {
|
||||
return "Response Finished";
|
||||
} else {
|
||||
return trigger;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<p className="text-slate-400">
|
||||
{cleanedTriggers
|
||||
.sort((a, b) => {
|
||||
const triggerOrder = {
|
||||
"Response Created": 1,
|
||||
"Response Updated": 2,
|
||||
"Response Finished": 3,
|
||||
};
|
||||
|
||||
return triggerOrder[a] - triggerOrder[b];
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
|
||||
return (
|
||||
<div className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{webhook.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 my-auto whitespace-nowrap pl-6 text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">{renderSelectedSurveysText(webhook, surveys)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">{renderSelectedTriggersText(webhook)}</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSinceConditionally(webhook.createdAt.toString())}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
} from "@formbricks/ui";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/testEndpoint";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
environmentId: string;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WebhookSettingsTab({
|
||||
environmentId,
|
||||
webhook,
|
||||
surveys,
|
||||
setOpen,
|
||||
}: ActionSettingsTabProps) {
|
||||
const router = useRouter();
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>(webhook.triggers);
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>(webhook.surveyIds);
|
||||
const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
|
||||
const handleTestEndpoint = async () => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
}
|
||||
};
|
||||
const renderSelectedSurveysText = () => {
|
||||
if (selectedSurveys.length === 0) {
|
||||
return <p className="text-slate-400">Select Surveys for this webhook</p>;
|
||||
} else {
|
||||
const selectedSurveyNames = selectedSurveys.map((surveyId) => {
|
||||
const survey = surveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
};
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
url: webhook.url,
|
||||
triggers: webhook.triggers,
|
||||
surveyIds: webhook.surveyIds,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (selectedTriggers.length === 0) {
|
||||
toast.error("Please select at least one trigger");
|
||||
return;
|
||||
}
|
||||
const updatedData: TWebhookInput = {
|
||||
url: data.url as string,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
setIsUpdatingWebhook(true);
|
||||
await updateWebhook(environmentId, webhook.id, updatedData);
|
||||
router.refresh();
|
||||
setIsUpdatingWebhook(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId) => {
|
||||
setSelectedSurveys((prevSelectedSurveys) => {
|
||||
if (prevSelectedSurveys.includes(surveyId)) {
|
||||
return prevSelectedSurveys.filter((id) => id !== surveyId);
|
||||
} else {
|
||||
return [...prevSelectedSurveys, surveyId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue) => {
|
||||
setSelectedTriggers((prevValues) => {
|
||||
if (prevValues.includes(selectedValue)) {
|
||||
return prevValues.filter((value) => value !== selectedValue);
|
||||
} else {
|
||||
return [...prevValues, selectedValue];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="col-span-1">
|
||||
<Label>URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
{...register("url", {
|
||||
value: testEndpointInput,
|
||||
})}
|
||||
type="text"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint();
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Triggers</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseCreated"
|
||||
checked={selectedTriggers.includes("responseCreated")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseCreated");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Created</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseUpdated"
|
||||
checked={selectedTriggers.includes("responseUpdated")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseUpdated");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Updated</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3">
|
||||
<Checkbox
|
||||
value="responseFinished"
|
||||
checked={selectedTriggers.includes("responseFinished")}
|
||||
onCheckedChange={() => {
|
||||
handleCheckboxChange("responseFinished");
|
||||
}}
|
||||
/>
|
||||
<Label className="flex cursor-pointer items-center">Response Finished</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surveys to enable</Label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ">
|
||||
{renderSelectedSurveysText()}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full bg-slate-50 text-slate-700" align="start" side="bottom">
|
||||
{surveys.map((survey) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={survey.id}
|
||||
checked={selectedSurveys.includes(survey.id)}
|
||||
onCheckedChange={() => handleSelectedSurveyChange(survey.id)}>
|
||||
{survey.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
StartIcon={TrashIcon}
|
||||
className="mr-3">
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" href="https://formbricks.com/docs" target="_blank">
|
||||
Read Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isUpdatingWebhook}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteDialog
|
||||
open={openDeleteDialog}
|
||||
setOpen={setOpenDeleteDialog}
|
||||
deleteWhat={"Webhook"}
|
||||
text="Are you sure you want to delete this Webhook? This will stop sending you any further notifications."
|
||||
onDelete={async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await deleteWebhook(webhook.id);
|
||||
router.refresh();
|
||||
toast.success("Webhook deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import AddWebhookModal from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/AddWebhookModal";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookDetailModal";
|
||||
|
||||
export default function WebhookTable({
|
||||
environmentId,
|
||||
webhooks,
|
||||
surveys,
|
||||
children: [TableHeading, webhookRows],
|
||||
}: {
|
||||
environmentId: string;
|
||||
webhooks: TWebhook[];
|
||||
surveys: TSurvey[];
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
}) {
|
||||
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
|
||||
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
|
||||
|
||||
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
|
||||
environmentId,
|
||||
id: "",
|
||||
url: "",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
|
||||
e.preventDefault();
|
||||
setActiveWebhook(webhook);
|
||||
setWebhookDetailModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setAddWebhookModalOpen(true);
|
||||
}}>
|
||||
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
{TableHeading}
|
||||
<div className="grid-cols-7">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleOpenWebhookDetailModalClick(e, webhook);
|
||||
}}
|
||||
className="w-full"
|
||||
key={webhook.id}>
|
||||
{webhookRows[index]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<WebhookModal
|
||||
environmentId={environmentId}
|
||||
open={isWebhookDetailModalOpen}
|
||||
setOpen={setWebhookDetailModalOpen}
|
||||
webhook={activeWebhook}
|
||||
surveys={surveys}
|
||||
/>
|
||||
<AddWebhookModal
|
||||
environmentId={environmentId}
|
||||
surveys={surveys}
|
||||
open={isAddWebhookModalOpen}
|
||||
setOpen={setAddWebhookModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function WebhookTableHeading() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">Edit</span>
|
||||
<div className="col-span-4 pl-6 ">URL</div>
|
||||
<div className="col-span-4 text-center">Surveys</div>
|
||||
<div className="col-span-2 text-center ">Triggers</div>
|
||||
<div className="col-span-2 text-center">Updated</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookRowData";
|
||||
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTable";
|
||||
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTableHeading";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
|
||||
export default async function CustomWebhookPage({ params }) {
|
||||
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {
|
||||
if (a.createdAt > b.createdAt) return -1;
|
||||
if (a.createdAt < b.createdAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
|
||||
<WebhookTableHeading />
|
||||
{webhooks.map((webhook) => (
|
||||
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
|
||||
))}
|
||||
</WebhookTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
export const testEndpoint = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
formbricks: "test endpoint",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const statusCode = response.status;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return true;
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
throw new Error(`Request failed with status code ${statusCode}: ${errorMessage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error while fetching the URL: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import JsLogo from "@/images/jslogo.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
export default function IntegrationsPage({ params }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
|
||||
@@ -22,6 +22,13 @@ export default function IntegrationsPage() {
|
||||
description="Integrate Formbricks with 5000+ apps via Zapier"
|
||||
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
|
||||
/>
|
||||
<Card
|
||||
connectHref={`/environments/${params.environmentId}/integrations/custom-webhook`}
|
||||
docsHref="https://formbricks.com/docs/webhook-api/overview"
|
||||
label="Custom Webhook"
|
||||
description="Trigger Webhooks based on actions in your surveys"
|
||||
icon={<Image src={JsLogo} alt="Javascript Logo" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { BackIcon } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -52,6 +55,30 @@ export const createWebhook = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebhook = async (
|
||||
environmentId: string,
|
||||
webhookId: string,
|
||||
webhookInput: Partial<TWebhookInput>
|
||||
): Promise<TWebhook> => {
|
||||
try {
|
||||
const result = await prisma.webhook.update({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
triggers: webhookInput.triggers,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(
|
||||
`Database error when updating webhook with ID ${webhookId} for environment ${environmentId}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
|
||||
try {
|
||||
return await prisma.webhook.delete({
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ZWebhook = z.object({
|
||||
url: z.string().url(),
|
||||
environmentId: z.string().cuid2(),
|
||||
triggers: z.array(ZPipelineTrigger),
|
||||
surveyIds: z.array(z.string().cuid2()),
|
||||
});
|
||||
|
||||
export type TWebhook = z.infer<typeof ZWebhook>;
|
||||
|
||||
Reference in New Issue
Block a user