diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index a5e083b637..f309b442b5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -1,9 +1,10 @@ +import JsLogo from "@/images/jslogo.png"; +import WebhookLogo from "@/images/webhook.png"; +import ZapierLogo from "@/images/zapier-small.png"; import { Card } from "@formbricks/ui"; 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

@@ -11,17 +12,34 @@ export default function IntegrationsPage() {
} /> } /> + } + />
); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal.tsx new file mode 100644 index 0000000000..31cd95a83b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal.tsx @@ -0,0 +1,231 @@ +import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup"; +import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup"; +import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers"; +import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint"; +import Modal from "@/components/shared/Modal"; +import { createWebhook } from "@formbricks/lib/services/webhook"; +import { TPipelineTrigger } from "@formbricks/types/v1/pipelines"; +import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TWebhookInput } from "@formbricks/types/v1/webhooks"; +import { Button, Input, Label } from "@formbricks/ui"; +import clsx from "clsx"; +import { Webhook } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; + +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, + register, + formState: { isSubmitting }, + } = useForm(); + + const [testEndpointInput, setTestEndpointInput] = useState(""); + const [hittingEndpoint, setHittingEndpoint] = useState(false); + const [endpointAccessible, setEndpointAccessible] = useState(); + const [selectedTriggers, setSelectedTriggers] = useState([]); + const [selectedSurveys, setSelectedSurveys] = useState([]); + const [selectedAllSurveys, setSelectedAllSurveys] = useState(false); + const [creatingWebhook, setCreatingWebhook] = useState(false); + + 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: string) => { + setSelectedSurveys((prevSelectedSurveys: string[]) => + prevSelectedSurveys.includes(surveyId) + ? prevSelectedSurveys.filter((id) => id !== surveyId) + : [...prevSelectedSurveys, surveyId] + ); + }; + + const handleCheckboxChange = (selectedValue: TPipelineTrigger) => { + setSelectedTriggers((prevValues) => + prevValues.includes(selectedValue) + ? prevValues.filter((value) => value !== selectedValue) + : [...prevValues, selectedValue] + ); + }; + + const submitWebhook = async (data: TWebhookInput): Promise => { + if (!isSubmitting) { + try { + setCreatingWebhook(true); + if (!testEndpointInput || testEndpointInput === "") { + throw new Error("Please enter a URL"); + } + if (selectedTriggers.length === 0) { + throw new Error("Please select at least one trigger"); + } + + if (!selectedAllSurveys && selectedSurveys.length === 0) { + throw new Error("Please select at least one survey"); + } + + const endpointHitSuccessfully = await handleTestEndpoint(false); + if (!endpointHitSuccessfully) return; + + const updatedData: TWebhookInput = { + name: data.name, + url: testEndpointInput, + triggers: selectedTriggers, + surveyIds: selectedSurveys, + }; + + await createWebhook(environmentId, updatedData); + router.refresh(); + setOpenWithStates(false); + toast.success("Webhook added successfully."); + } catch (e) { + toast.error(e.message); + } finally { + setCreatingWebhook(false); + } + } + }; + + const setOpenWithStates = (isOpen: boolean) => { + setOpen(isOpen); + reset(); + setTestEndpointInput(""); + setEndpointAccessible(undefined); + setSelectedSurveys([]); + setSelectedTriggers([]); + setSelectedAllSurveys(false); + }; + + return ( + +
+
+
+
+
+ +
+
+
Add Webhook
+
Send survey response data to a custom endpoint
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+ +
+ { + 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" + /> + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers.tsx new file mode 100644 index 0000000000..594bc00872 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers.tsx @@ -0,0 +1,7 @@ +import { TPipelineTrigger } from "@formbricks/types/v1/pipelines"; + +export const triggers = [ + { title: "Response Created", value: "responseCreated" as TPipelineTrigger }, + { title: "Response Updated", value: "responseUpdated" as TPipelineTrigger }, + { title: "Response Finished", value: "responseFinished" as TPipelineTrigger }, +]; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup.tsx new file mode 100644 index 0000000000..f7b7bfe645 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Checkbox } from "@formbricks/ui"; +import { TSurvey } from "@formbricks/types/v1/surveys"; + +interface SurveyCheckboxGroupProps { + surveys: TSurvey[]; + selectedSurveys: string[]; + selectedAllSurveys: boolean; + onSelectAllSurveys: () => void; + onSelectedSurveyChange: (surveyId: string) => void; +} + +export const SurveyCheckboxGroup: React.FC = ({ + surveys, + selectedSurveys, + selectedAllSurveys, + onSelectAllSurveys, + onSelectedSurveyChange, +}) => { + return ( +
+
+
+ + +
+ {surveys.map((survey) => ( +
+ onSelectedSurveyChange(survey.id)} + /> + +
+ ))} +
+
+ ); +}; + +export default SurveyCheckboxGroup; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup.tsx new file mode 100644 index 0000000000..7bf21b43f1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Checkbox } from "@formbricks/ui"; +import { TPipelineTrigger } from "@formbricks/types/v1/pipelines"; + +interface TriggerCheckboxGroupProps { + triggers: { title: string; value: TPipelineTrigger }[]; + selectedTriggers: TPipelineTrigger[]; + onCheckboxChange: (selectedValue: TPipelineTrigger) => void; +} + +export const TriggerCheckboxGroup: React.FC = ({ + 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 : "-"}

+
+ +
+ +

{webhook.url}

+
+ +
+ + + {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 ( +
+
+
+ +
+ +
+
+ +
+ +
+ { + 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" + /> + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + + +
+ +
+ +
+
+
+ { + 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 && ( - )} {docsHref && ( - )}