= ({
+ triggers,
+ selectedTriggers,
+ onCheckboxChange,
+}) => {
+ return (
+
+
+ {triggers.map((trigger) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+export default TriggerCheckboxGroup;
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal.tsx
new file mode 100644
index 0000000000..042b1c1832
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal.tsx
@@ -0,0 +1,47 @@
+import ModalWithTabs from "@/components/shared/ModalWithTabs";
+import { TWebhook } from "@formbricks/types/v1/webhooks";
+import WebhookOverviewTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab";
+import WebhookSettingsTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab";
+import { TSurvey } from "@formbricks/types/v1/surveys";
+import { Webhook } from "lucide-react";
+
+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: "Overview",
+ children: ,
+ },
+ {
+ title: "Settings",
+ children: (
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+ }
+ label={webhook.name ? webhook.name : webhook.url}
+ description={""}
+ />
+ >
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab.tsx
new file mode 100644
index 0000000000..65af945134
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab.tsx
@@ -0,0 +1,83 @@
+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 getSurveyNamesForWebhook = (webhook: TWebhook, allSurveys: TSurvey[]): string[] => {
+ if (webhook.surveyIds.length === 0) {
+ return allSurveys.map((survey) => survey.name);
+ } else {
+ return webhook.surveyIds.map((surveyId) => {
+ const survey = allSurveys.find((survey) => survey.id === surveyId);
+ return survey ? survey.name : "";
+ });
+ }
+};
+
+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 WebhookOverviewTab({ webhook, surveys }: ActivityTabProps) {
+ return (
+
+
+
+
+
{webhook.name ? webhook.name : "-"}
+
+
+
+
+
+
+
+ {getSurveyNamesForWebhook(webhook, surveys).map((surveyName, index) => (
+
+ {surveyName}
+
+ ))}
+
+
+
+ {webhook.triggers.map((triggerId) => (
+
+ {convertTriggerIdToName(triggerId)}
+
+ ))}
+
+
+
+
+
+
+ {convertDateTimeStringShort(webhook.createdAt?.toString())}
+
+
+
+
+
+ {convertDateTimeStringShort(webhook.updatedAt?.toString())}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData.tsx
new file mode 100644
index 0000000000..e1ba2b4160
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData.tsx
@@ -0,0 +1,81 @@
+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) {
+ const allSurveyNames = allSurveys.map((survey) => survey.name);
+ return {allSurveyNames.join(", ")}
;
+ } else {
+ const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
+ const survey = allSurveys.find((survey) => survey.id === surveyId);
+ return survey ? survey.name : "";
+ });
+ return {selectedSurveyNames.join(", ")}
;
+ }
+};
+
+const renderSelectedTriggersText = (webhook: TWebhook) => {
+ if (webhook.triggers.length === 0) {
+ return No Triggers
;
+ } 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 (
+
+ {cleanedTriggers
+ .sort((a, b) => {
+ const triggerOrder = {
+ "Response Created": 1,
+ "Response Updated": 2,
+ "Response Finished": 3,
+ };
+
+ return triggerOrder[a] - triggerOrder[b];
+ })
+ .join(", ")}
+
+ );
+ }
+};
+
+export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
+ return (
+
+
+
+
+ {webhook.name ? (
+
+
{webhook.name}
+
{webhook.url}
+
+ ) : (
+
{webhook.url}
+ )}
+
+
+
+
+ {renderSelectedSurveysText(webhook, surveys)}
+
+
+ {renderSelectedTriggersText(webhook)}
+
+
+ {timeSinceConditionally(webhook.createdAt.toString())}
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab.tsx
new file mode 100644
index 0000000000..0e66b2aa21
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import DeleteDialog from "@/components/shared/DeleteDialog";
+import { Button, 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 { TSurvey } from "@formbricks/types/v1/surveys";
+import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
+import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
+import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
+import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
+
+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 { register, handleSubmit } = useForm({
+ defaultValues: {
+ name: webhook.name,
+ url: webhook.url,
+ triggers: webhook.triggers,
+ surveyIds: webhook.surveyIds,
+ },
+ });
+
+ const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
+ const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
+ const [selectedTriggers, setSelectedTriggers] = useState(webhook.triggers);
+ const [selectedSurveys, setSelectedSurveys] = useState(webhook.surveyIds);
+ const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
+ const [endpointAccessible, setEndpointAccessible] = useState();
+ const [hittingEndpoint, setHittingEndpoint] = useState(false);
+ const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
+
+ const handleTestEndpoint = async (sendSuccessToast: boolean) => {
+ try {
+ setHittingEndpoint(true);
+ await testEndpoint(testEndpointInput);
+ setHittingEndpoint(false);
+ if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
+ setEndpointAccessible(true);
+ return true;
+ } catch (err) {
+ setHittingEndpoint(false);
+ toast.error("Oh no! We are unable to ping the webhook!");
+ setEndpointAccessible(false);
+ return false;
+ }
+ };
+
+ const handleSelectAllSurveys = () => {
+ setSelectedAllSurveys(!selectedAllSurveys);
+ setSelectedSurveys([]);
+ };
+
+ 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 onSubmit = async (data) => {
+ if (selectedTriggers.length === 0) {
+ toast.error("Please select at least one trigger");
+ return;
+ }
+
+ if (!selectedAllSurveys && selectedSurveys.length === 0) {
+ toast.error("Please select at least one survey");
+ return;
+ }
+ const endpointHitSuccessfully = await handleTestEndpoint(false);
+ if (!endpointHitSuccessfully) {
+ return;
+ }
+
+ const updatedData: TWebhookInput = {
+ name: data.name,
+ url: data.url as string,
+ triggers: selectedTriggers,
+ surveyIds: selectedSurveys,
+ };
+ setIsUpdatingWebhook(true);
+ await updateWebhook(environmentId, webhook.id, updatedData);
+ toast.success("Webhook updated successfully.");
+ router.refresh();
+ setIsUpdatingWebhook(false);
+ setOpen(false);
+ };
+
+ return (
+
+
+
{
+ setOpen(false);
+ try {
+ await deleteWebhook(webhook.id);
+ router.refresh();
+ toast.success("Webhook deleted successfully");
+ } catch (error) {
+ toast.error("Something went wrong. Please try again.");
+ }
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable.tsx
new file mode 100644
index 0000000000..5824a67a5a
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { Button } from "@formbricks/ui";
+import { useState } from "react";
+import { TWebhook } from "@formbricks/types/v1/webhooks";
+import AddWebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal";
+import { TSurvey } from "@formbricks/types/v1/surveys";
+import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal";
+import { Webhook } from "lucide-react";
+import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
+
+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({
+ environmentId,
+ id: "",
+ name: "",
+ url: "",
+ triggers: [],
+ surveyIds: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
+ e.preventDefault();
+ setActiveWebhook(webhook);
+ setWebhookDetailModalOpen(true);
+ };
+
+ return (
+ <>
+
+
+
+
+ {webhooks.length === 0 ? (
+
+ ) : (
+
+ {TableHeading}
+
+ {webhooks.map((webhook, index) => (
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading.tsx
new file mode 100644
index 0000000000..43de96084d
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading.tsx
@@ -0,0 +1,13 @@
+export default function WebhookTableHeading() {
+ return (
+ <>
+
+
Edit
+
Webhook
+
Surveys
+
Triggers
+
Updated
+
+ >
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/loading.tsx
new file mode 100644
index 0000000000..8830872270
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/loading.tsx
@@ -0,0 +1,58 @@
+import GoBackButton from "@/components/shared/GoBackButton";
+import { Button } from "@formbricks/ui";
+import { Webhook } from "lucide-react";
+
+export default function Loading() {
+ return (
+ <>
+
+
+
+
+
+
+
+
Edit
+
Webhook
+
Surveys
+
Triggers
+
Updated
+
+
+ {[...Array(3)].map((_, index) => (
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx
new file mode 100644
index 0000000000..3dd1d215fa
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx
@@ -0,0 +1,26 @@
+import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData";
+import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
+import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/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 (
+ <>
+
+
+
+ {webhooks.map((webhook) => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint.tsx
new file mode 100644
index 0000000000..a19dc9a03f
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint.tsx
@@ -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}`);
+ }
+};
diff --git a/apps/web/components/shared/GoBackButton.tsx b/apps/web/components/shared/GoBackButton.tsx
index 8869ac65ac..332683eec7 100644
--- a/apps/web/components/shared/GoBackButton.tsx
+++ b/apps/web/components/shared/GoBackButton.tsx
@@ -1,3 +1,5 @@
+'use client';
+
import { BackIcon } from "@formbricks/ui";
import { useRouter } from "next/navigation";
diff --git a/apps/web/images/webhook.png b/apps/web/images/webhook.png
new file mode 100644
index 0000000000..8a31a4deb0
Binary files /dev/null and b/apps/web/images/webhook.png differ
diff --git a/packages/database/migrations/20230809132511_add_name_to_webhook/migration.sql b/packages/database/migrations/20230809132511_add_name_to_webhook/migration.sql
new file mode 100644
index 0000000000..4356a52628
--- /dev/null
+++ b/packages/database/migrations/20230809132511_add_name_to_webhook/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Webhook" ADD COLUMN "name" TEXT;
diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma
index f7b39053d9..8042ca489d 100644
--- a/packages/database/schema.prisma
+++ b/packages/database/schema.prisma
@@ -30,6 +30,7 @@ enum PipelineTriggers {
model Webhook {
id String @id @default(cuid())
+ name String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
url String
diff --git a/packages/lib/services/webhook.ts b/packages/lib/services/webhook.ts
index 7cc920e094..400afa6b25 100644
--- a/packages/lib/services/webhook.ts
+++ b/packages/lib/services/webhook.ts
@@ -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";
@@ -34,6 +37,7 @@ export const createWebhook = async (
}
return await prisma.webhook.create({
data: {
+ name: webhookInput.name,
url: webhookInput.url,
triggers: webhookInput.triggers,
surveyIds: webhookInput.surveyIds || [],
@@ -52,6 +56,31 @@ export const createWebhook = async (
}
};
+export const updateWebhook = async (
+ environmentId: string,
+ webhookId: string,
+ webhookInput: Partial
+): Promise => {
+ try {
+ const result = await prisma.webhook.update({
+ where: {
+ id: webhookId,
+ },
+ data: {
+ name: webhookInput.name,
+ 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 => {
try {
return await prisma.webhook.delete({
diff --git a/packages/types/v1/webhooks.ts b/packages/types/v1/webhooks.ts
index bead9b3628..df64c3d2b2 100644
--- a/packages/types/v1/webhooks.ts
+++ b/packages/types/v1/webhooks.ts
@@ -3,17 +3,20 @@ import { ZPipelineTrigger } from "./pipelines";
export const ZWebhook = z.object({
id: z.string().cuid2(),
+ name: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
environmentId: z.string().cuid2(),
triggers: z.array(ZPipelineTrigger),
+ surveyIds: z.array(z.string().cuid2()),
});
export type TWebhook = z.infer;
export const ZWebhookInput = z.object({
url: z.string().url(),
+ name: z.string().nullable(),
triggers: z.array(ZPipelineTrigger),
surveyIds: z.array(z.string().cuid2()).optional(),
});
diff --git a/packages/ui/components/Card.tsx b/packages/ui/components/Card.tsx
index ad771f5c6b..84b05bb67e 100644
--- a/packages/ui/components/Card.tsx
+++ b/packages/ui/components/Card.tsx
@@ -1,8 +1,12 @@
import { Button } from "./Button";
interface CardProps {
+ connectText?: string;
connectHref?: string;
+ connectNewTab?: boolean;
+ docsText?: string;
docsHref?: string;
+ docsNewTab?: boolean;
label: string;
description: string;
icon?: React.ReactNode;
@@ -10,20 +14,30 @@ interface CardProps {
export type { CardProps };
-export const Card: React.FC = ({ connectHref, docsHref, label, description, icon }) => (
+export const Card: React.FC = ({
+ connectText,
+ connectHref,
+ connectNewTab,
+ docsText,
+ docsHref,
+ docsNewTab,
+ label,
+ description,
+ icon,
+}) => (
{icon &&
{icon}
}
{label}
{description}
{connectHref && (
-