Add UI to create webhooks on integrations page

Add UI to create webhooks on integrations page
This commit is contained in:
Johannes
2023-08-09 15:31:14 +02:00
committed by GitHub
21 changed files with 1087 additions and 8 deletions

View File

@@ -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 (
<div>
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
@@ -11,17 +12,34 @@ export default function IntegrationsPage() {
<div className="grid grid-cols-3 gap-6">
<Card
docsHref="https://formbricks.com/docs/getting-started/nextjs-app"
docsText="Docs"
docsNewTab={true}
label="Javascript Widget"
description="Integrate Formbricks into your Webapp"
icon={<Image src={JsLogo} alt="Javascript Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/zapier"
docsText="Docs"
docsNewTab={true}
connectHref="https://zapier.com/apps/formbricks/integrations"
connectText="Connect"
connectNewTab={true}
label="Zapier"
description="Integrate Formbricks with 5000+ apps via Zapier"
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
connectText="Manage Webhooks"
connectNewTab={false}
docsHref="https://formbricks.com/docs/webhook-api/overview"
docsText="Docs"
docsNewTab={true}
label="Webhooks"
description="Trigger Webhooks based on actions in your surveys"
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
/>
</div>
</div>
);

View File

@@ -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<boolean>(false);
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
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<void> => {
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 (
<Modal open={open} setOpen={setOpenWithStates} 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">
<Webhook />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Webhook</div>
<div className="text-sm text-slate-500">Send survey response data to a custom endpoint</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 htmlFor="name">Name</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
placeholder="Optional: Label your webhook for easy identification"
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">URL</Label>
<div className="mt-1 flex">
<Input
type="url"
id="URL"
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(true);
}}>
Test Endpoint
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">Triggers</Label>
<TriggerCheckboxGroup
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
/>
</div>
<div>
<Label htmlFor="Surveys">Surveys</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
/>
</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={() => {
setOpenWithStates(false);
}}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={creatingWebhook}>
Add Webhook
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -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 },
];

View File

@@ -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<SurveyCheckboxGroupProps> = ({
surveys,
selectedSurveys,
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
<div className="my-1 flex items-center space-x-2">
<Checkbox
type="button"
id="allSurveys"
className="bg-white"
value=""
checked={selectedAllSurveys}
onCheckedChange={onSelectAllSurveys}
/>
<label
htmlFor="allSurveys"
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
All current and new surveys
</label>
</div>
{surveys.map((survey) => (
<div key={survey.id} className="my-1 flex items-center space-x-2">
<Checkbox
type="button"
id={survey.id}
value={survey.id}
className="bg-white"
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
disabled={selectedAllSurveys}
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
/>
<label
htmlFor={survey.id}
className={`flex cursor-pointer items-center ${
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
}`}>
{survey.name}
</label>
</div>
))}
</div>
</div>
);
};
export default SurveyCheckboxGroup;

View File

@@ -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<TriggerCheckboxGroupProps> = ({
triggers,
selectedTriggers,
onCheckboxChange,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{triggers.map((trigger) => (
<div key={trigger.value} className="my-1 flex items-center space-x-2">
<label htmlFor={trigger.value} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={trigger.value}
value={trigger.value}
className="bg-white"
checked={selectedTriggers.includes(trigger.value)}
onCheckedChange={() => {
onCheckboxChange(trigger.value);
}}
/>
<span className="ml-2">{trigger.title}</span>
</label>
</div>
))}
</div>
</div>
);
};
export default TriggerCheckboxGroup;

View File

@@ -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: <WebhookOverviewTab 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={<Webhook />}
label={webhook.name ? webhook.name : webhook.url}
description={""}
/>
</>
);
}

View File

@@ -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 (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
<div>
<Label className="text-slate-500">Name</Label>
<p className="text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
</div>
<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>
{getSurveyNamesForWebhook(webhook, surveys).map((surveyName, index) => (
<p key={index} className="text-sm text-slate-900">
{surveyName}
</p>
))}
</div>
<div>
<Label className="text-slate-500">Triggers</Label>
{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>
);
}

View File

@@ -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 <p className="text-slate-400">{allSurveyNames.join(", ")}</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">
{webhook.name ? (
<div className="text-left">
<div className="font-medium text-slate-900">{webhook.name}</div>
<div className="text-xs text-slate-400">{webhook.url}</div>
</div>
) : (
<div className="font-medium text-slate-900">{webhook.url}</div>
)}
</div>
</div>
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}
</div>
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
{renderSelectedTriggersText(webhook)}
</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>
);
}

View File

@@ -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<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 [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 (
<div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="col-span-1">
<Label htmlFor="Name">Name</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
defaultValue={webhook.name ?? ""}
placeholder="Optional: Label your webhook for easy identification"
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">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(true);
}}>
Test Endpoint
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">Triggers</Label>
<TriggerCheckboxGroup
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
/>
</div>
<div>
<Label htmlFor="Surveys">Surveys</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
/>
</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/webhook-api/overview"
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>
);
}

View File

@@ -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<TWebhook>({
environmentId,
id: "",
name: "",
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);
}}>
<Webhook className="mr-2 h-5 w-5 text-white" />
Add Webhook
</Button>
</div>
{webhooks.length === 0 ? (
<EmptySpaceFiller
type="table"
environmentId={environmentId}
noWidgetRequired={true}
emptyMessage="Your webhooks will appear here as soon as you add them. ⏲️"
/>
) : (
<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}
/>
</>
);
}

View File

@@ -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 ">Webhook</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>
</>
);
}

View File

@@ -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 (
<>
<GoBackButton />
<div className="mb-6 text-right">
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
<Webhook className="mr-2 h-5 w-5 text-white" />
Loading
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<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 ">Webhook</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>
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
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="text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
<div className="text-center"></div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -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 (
<>
<GoBackButton />
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
))}
</WebhookTable>
</>
);
}

View File

@@ -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}`);
}
};

View File

@@ -1,3 +1,5 @@
'use client';
import { BackIcon } from "@formbricks/ui";
import { useRouter } from "next/navigation";

BIN
apps/web/images/webhook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "name" TEXT;

View File

@@ -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

View File

@@ -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<TWebhookInput>
): Promise<TWebhook> => {
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<TWebhook> => {
try {
return await prisma.webhook.delete({

View File

@@ -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<typeof ZWebhook>;
export const ZWebhookInput = z.object({
url: z.string().url(),
name: z.string().nullable(),
triggers: z.array(ZPipelineTrigger),
surveyIds: z.array(z.string().cuid2()).optional(),
});

View File

@@ -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<CardProps> = ({ connectHref, docsHref, label, description, icon }) => (
export const Card: React.FC<CardProps> = ({
connectText,
connectHref,
connectNewTab,
docsText,
docsHref,
docsNewTab,
label,
description,
icon,
}) => (
<div className="rounded-lg bg-white p-8 text-left shadow-sm ">
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
<p className="text-xs text-slate-500">{description}</p>
<div className="mt-4 flex space-x-2">
{connectHref && (
<Button href={connectHref} target="_blank" size="sm" variant="darkCTA">
Connect
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm" variant="darkCTA">
{connectText}
</Button>
)}
{docsHref && (
<Button href={docsHref} target="_blank" size="sm" variant="secondary">
Docs
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
{docsText}
</Button>
)}
</div>