diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/AddWebhookModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/AddWebhookModal.tsx new file mode 100644 index 0000000000..2f8ada2987 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/AddWebhookModal.tsx @@ -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 => { + 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

Select Surveys for this webhook

; + } else { + const selectedSurveyNames = selectedSurveys.map((surveyId) => { + const survey = surveys.find((survey) => survey.id === surveyId); + return survey ? survey.name : ""; + }); + return

{selectedSurveyNames.join(", ")}

; + } + }; + const [testEndpointInput, setTestEndpointInput] = useState(""); + const [endpointAccessible, setEndpointAccessible] = useState(); + const [hittingEndpoint, setHittingEndpoint] = useState(false); + + const [selectedSurveys, setSelectedSurveys] = useState([]); + const [selectedTriggers, setSelectedTriggers] = useState([]); + + 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 ( + +
+
+
+
+
+ +
+
+
Add Webhook
+
+ Send alerts to your own custom endpoints when an action is striggered in a survey. +
+
+
+
+
+
+
+
+
+ +
+ { + 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" + /> + +
+
+
+ +
+
+ { + handleCheckboxChange("responseCreated"); + }} + /> + +
+
+ { + handleCheckboxChange("responseUpdated"); + }} + /> + +
+
+ { + handleCheckboxChange("responseFinished"); + }} + /> + +
+
+
+ +
+ + + + +
+ {renderSelectedSurveysText()} + +
+
+ + {surveys.map((survey) => ( + e.preventDefault()} + onCheckedChange={() => handleSelectedSurveyChange(survey.id)}> + {survey.name} + + ))} + +
+
+
+
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookActivityTab.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookActivityTab.tsx new file mode 100644 index 0000000000..7f7713b830 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookActivityTab.tsx @@ -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 ( +
+
+
+ +

{webhook.url}

+
+ +
+ + {webhook.surveyIds.length === 0 &&

-

} + {webhook.surveyIds + .map((surveyId) => surveys.find((survey) => survey.id === surveyId)?.name) + .map((surveyName) => ( +

+ {surveyName} +

+ ))} +
+
+ + {webhook.triggers.length === 0 &&

-

} + {webhook.triggers.map((triggerId) => ( +

+ {convertTriggerIdToName(triggerId)} +

+ ))} +
+
+
+
+ +

+ {convertDateTimeStringShort(webhook.createdAt?.toString())} +

+
+
+ +

+ {convertDateTimeStringShort(webhook.updatedAt?.toString())} +

+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookDetailModal.tsx new file mode 100644 index 0000000000..995423caef --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookDetailModal.tsx @@ -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: , + }, + { + title: "Settings", + children: ( + + ), + }, + ]; + + return ( + <> + } + label={webhook.url} + description={""} + /> + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookRowData.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookRowData.tsx new file mode 100644 index 0000000000..f3ef75279d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookRowData.tsx @@ -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

No Surveys

; + } 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.url}
+
+
+
+
+
{renderSelectedSurveysText(webhook, surveys)}
+
+
+
{renderSelectedTriggersText(webhook)}
+
+ +
+ {timeSinceConditionally(webhook.createdAt.toString())} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookSettingsTab.tsx new file mode 100644 index 0000000000..fdc60266e4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookSettingsTab.tsx @@ -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(webhook.triggers); + const [selectedSurveys, setSelectedSurveys] = useState(webhook.surveyIds); + const [testEndpointInput, setTestEndpointInput] = useState(webhook.url); + const [endpointAccessible, setEndpointAccessible] = useState(); + const [hittingEndpoint, setHittingEndpoint] = useState(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

Select Surveys for this webhook

; + } else { + const selectedSurveyNames = selectedSurveys.map((surveyId) => { + const survey = surveys.find((survey) => survey.id === surveyId); + return survey ? survey.name : ""; + }); + return

{selectedSurveyNames.join(", ")}

; + } + }; + 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 ( +
+
+
+ +
+ { + 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" + /> + +
+
+ +
+ +
+
+ { + handleCheckboxChange("responseCreated"); + }} + /> + +
+
+ { + handleCheckboxChange("responseUpdated"); + }} + /> + +
+
+ { + handleCheckboxChange("responseFinished"); + }} + /> + +
+
+
+ +
+ + + + +
+ {renderSelectedSurveysText()} + +
+
+ + {surveys.map((survey) => ( + handleSelectedSurveyChange(survey.id)}> + {survey.name} + + ))} + +
+
+ +
+
+ + + +
+ +
+ +
+
+
+ { + 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/custom-webhook/WebhookTable.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTable.tsx new file mode 100644 index 0000000000..065ee1fa94 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTable.tsx @@ -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({ + environmentId, + id: "", + url: "", + triggers: [], + surveyIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => { + e.preventDefault(); + setActiveWebhook(webhook); + setWebhookDetailModalOpen(true); + }; + + return ( + <> +
+ +
+
+ {TableHeading} +
+ {webhooks.map((webhook, index) => ( + + ))} +
+
+ + + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTableHeading.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTableHeading.tsx new file mode 100644 index 0000000000..9c83d1d6fb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/WebhookTableHeading.tsx @@ -0,0 +1,13 @@ +export default function WebhookTableHeading() { + return ( + <> +
+ Edit +
URL
+
Surveys
+
Triggers
+
Updated
+
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/page.tsx new file mode 100644 index 0000000000..6dfb480031 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/page.tsx @@ -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 ( + <> + + + + {webhooks.map((webhook) => ( + + ))} + + + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/testEndpoint.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/testEndpoint.tsx new file mode 100644 index 0000000000..a19dc9a03f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/custom-webhook/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/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index a5e083b637..9e2e91e7ea 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -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 (

Integrations

@@ -22,6 +22,13 @@ export default function IntegrationsPage() { description="Integrate Formbricks with 5000+ apps via Zapier" icon={Zapier Logo} /> + } + />
); 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/packages/lib/services/webhook.ts b/packages/lib/services/webhook.ts index 7cc920e094..c278340191 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"; @@ -52,6 +55,30 @@ 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: { + 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..9582d8822c 100644 --- a/packages/types/v1/webhooks.ts +++ b/packages/types/v1/webhooks.ts @@ -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;