feat: Introduces Source to the Webhook Overview for automatically created webhooks (#724)

* feat: webhooks now have a source to diff between user and third party

* fix: capitalise first letter of source and increase vertical padding in row

* fix: update webhhok source type in prisma and cleanup services

* combine two migrations into one

* add actions file for webhook UI

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubham Palriwala
2023-09-14 08:03:47 +05:30
committed by GitHub
parent fa0d4ab83c
commit 05be97f43b
15 changed files with 120 additions and 38 deletions
@@ -3,7 +3,7 @@ import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integ
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 { createWebhookAction } from "./actions";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
@@ -97,11 +97,12 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
const updatedData: TWebhookInput = {
name: data.name,
url: testEndpointInput,
source: "user",
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
await createWebhook(environmentId, updatedData);
await createWebhookAction(environmentId, updatedData);
router.refresh();
setOpenWithStates(false);
toast.success("Webhook added successfully.");
@@ -194,6 +195,7 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
@@ -205,6 +207,7 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
/>
</div>
</div>
@@ -8,6 +8,7 @@ interface SurveyCheckboxGroupProps {
selectedAllSurveys: boolean;
onSelectAllSurveys: () => void;
onSelectedSurveyChange: (surveyId: string) => void;
allowChanges: boolean;
}
export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
@@ -16,6 +17,7 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
allowChanges,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
@@ -28,10 +30,13 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
value=""
checked={selectedAllSurveys}
onCheckedChange={onSelectAllSurveys}
disabled={!allowChanges}
/>
<label
htmlFor="allSurveys"
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""} ${
!allowChanges ? "cursor-not-allowed opacity-50" : ""
}`}>
All current and new surveys
</label>
</div>
@@ -43,14 +48,18 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
value={survey.id}
className="bg-white"
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
disabled={selectedAllSurveys}
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
disabled={selectedAllSurveys || !allowChanges}
onCheckedChange={() => {
if (allowChanges) {
onSelectedSurveyChange(survey.id);
}
}}
/>
<label
htmlFor={survey.id}
className={`flex cursor-pointer items-center ${
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
}`}>
} ${!allowChanges ? "cursor-not-allowed opacity-50" : ""}`}>
{survey.name}
</label>
</div>
@@ -6,19 +6,25 @@ interface TriggerCheckboxGroupProps {
triggers: { title: string; value: TPipelineTrigger }[];
selectedTriggers: TPipelineTrigger[];
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
allowChanges: boolean;
}
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
triggers,
selectedTriggers,
onCheckboxChange,
allowChanges,
}) => {
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">
<label
htmlFor={trigger.value}
className={`flex ${
!allowChanges ? "cursor-not-allowed opacity-50" : "cursor-pointer"
} items-center`}>
<Checkbox
type="button"
id={trigger.value}
@@ -26,8 +32,11 @@ export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
className="bg-white"
checked={selectedTriggers.includes(trigger.value)}
onCheckedChange={() => {
onCheckboxChange(trigger.value);
if (allowChanges) {
onCheckboxChange(trigger.value);
}
}}
disabled={!allowChanges}
/>
<span className="ml-2">{trigger.title}</span>
</label>
@@ -2,6 +2,7 @@ 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";
import { capitalizeFirstLetter } from "@/lib/utils";
interface ActivityTabProps {
webhook: TWebhook;
@@ -38,7 +39,14 @@ export default function WebhookOverviewTab({ webhook, surveys }: ActivityTabProp
<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>
<p className="truncate text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
</div>
<div>
<Label className="text-slate-500">Created by a Third Party</Label>
<p className="text-sm text-slate-900">
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
</p>
</div>
<div>
@@ -1,6 +1,8 @@
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import { Badge } from "@formbricks/ui";
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
@@ -51,8 +53,8 @@ const renderSelectedTriggersText = (webhook: TWebhook) => {
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="mt-2 grid h-auto grid-cols-12 content-center rounded-lg py-2 hover:bg-slate-100">
<div className="col-span-3 flex items-center truncate pl-6 text-sm">
<div className="flex items-center">
<div className="text-left">
{webhook.name ? (
@@ -66,6 +68,9 @@ export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook
</div>
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge text={capitalizeFirstLetter(webhook.source) || "User"} type="gray" size="tiny" />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}
</div>
@@ -9,7 +9,7 @@ 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 { deleteWebhookAction, updateWebhookAction } from "./actions";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
@@ -108,11 +108,12 @@ export default function WebhookSettingsTab({
const updatedData: TWebhookInput = {
name: data.name,
url: data.url as string,
source: data.source,
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
setIsUpdatingWebhook(true);
await updateWebhook(environmentId, webhook.id, updatedData);
await updateWebhookAction(environmentId, webhook.id, updatedData);
toast.success("Webhook updated successfully.");
router.refresh();
setIsUpdatingWebhook(false);
@@ -147,7 +148,9 @@ export default function WebhookSettingsTab({
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
readOnly={webhook.source !== "user"}
className={clsx(
webhook.source === "user" ? null : "cursor-not-allowed bg-gray-100 text-gray-500",
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
@@ -177,6 +180,7 @@ export default function WebhookSettingsTab({
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={webhook.source === "user"}
/>
</div>
@@ -188,19 +192,22 @@ export default function WebhookSettingsTab({
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={webhook.source === "user"}
/>
</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>
{webhook.source === "user" && (
<Button
type="button"
variant="warn"
onClick={() => setOpenDeleteDialog(true)}
StartIcon={TrashIcon}
className="mr-3">
Delete
</Button>
)}
<Button
variant="secondary"
@@ -225,7 +232,7 @@ export default function WebhookSettingsTab({
onDelete={async () => {
setOpen(false);
try {
await deleteWebhook(webhook.id);
await deleteWebhookAction(webhook.id);
router.refresh();
toast.success("Webhook deleted successfully");
} catch (error) {
@@ -28,6 +28,7 @@ export default function WebhookTable({
id: "",
name: "",
url: "",
source: "user",
triggers: [],
surveyIds: [],
createdAt: new Date(),
@@ -3,7 +3,8 @@ export default function WebhookTableHeading() {
<>
<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-3 pl-6 ">Webhook</div>
<div className="col-span-1 text-center">Source</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>
@@ -0,0 +1,23 @@
"use server";
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
export const createWebhookAction = async (
environmentId: string,
webhookInput: TWebhookInput
): Promise<TWebhook> => {
return await createWebhook(environmentId, webhookInput);
};
export const deleteWebhookAction = async (id: string): Promise<TWebhook> => {
return await deleteWebhook(id);
};
export const updateWebhookAction = async (
environmentId: string,
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<TWebhook> => {
return await updateWebhook(environmentId, webhookId, webhookInput);
};
@@ -18,7 +18,8 @@ export default function Loading() {
<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-3 pl-6 ">Webhook</div>
<div className="col-span-1 text-center">Source</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>
@@ -28,13 +29,18 @@ export default function Loading() {
<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="col-span-3 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-1 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-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>
+1 -1
View File
@@ -32,7 +32,7 @@ export default function ModalWithTabs({ open, setOpen, tabs, icon, label, descri
<Modal open={open} setOpen={setOpen} noPadding>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="mr-20 flex items-center justify-between truncate p-6">
<div className="flex items-center space-x-2">
{icon && <div className="mr-1.5 h-6 w-6 text-slate-500">{icon}</div>}
<div>
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "WehbhookSource" AS ENUM ('user', 'zapier');
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "source" "WehbhookSource" NOT NULL DEFAULT 'user';
+6
View File
@@ -28,12 +28,18 @@ enum PipelineTriggers {
responseFinished
}
enum WehbhookSource {
user
zapier
}
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
source WehbhookSource @default(user)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
triggers PipelineTriggers[]
+9 -12
View File
@@ -1,4 +1,3 @@
"use server";
import "server-only";
import { TWebhook, TWebhookInput, ZWebhookInput } from "@formbricks/types/v1/webhooks";
@@ -12,11 +11,12 @@ import { ResourceNotFoundError, DatabaseError, InvalidInputError } from "@formbr
export const getWebhooks = cache(async (environmentId: string): Promise<TWebhook[]> => {
validateInputs([environmentId, ZId]);
try {
return await prisma.webhook.findMany({
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
});
return webhooks;
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
@@ -38,14 +38,9 @@ export const createWebhook = async (
): Promise<TWebhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try {
if (!webhookInput.url || !webhookInput.triggers) {
throw new InvalidInputError("Missing URL or trigger in webhook input");
}
return await prisma.webhook.create({
let createdWebhook = await prisma.webhook.create({
data: {
name: webhookInput.name,
url: webhookInput.url,
triggers: webhookInput.triggers,
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
environment: {
connect: {
@@ -54,6 +49,7 @@ export const createWebhook = async (
},
},
});
return createdWebhook;
} catch (error) {
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
@@ -69,7 +65,7 @@ export const updateWebhook = async (
): Promise<TWebhook> => {
validateInputs([environmentId, ZId], [webhookId, ZId], [webhookInput, ZWebhookInput]);
try {
const result = await prisma.webhook.update({
const webhook = await prisma.webhook.update({
where: {
id: webhookId,
},
@@ -80,7 +76,7 @@ export const updateWebhook = async (
surveyIds: webhookInput.surveyIds || [],
},
});
return result;
return webhook;
} catch (error) {
throw new DatabaseError(
`Database error when updating webhook with ID ${webhookId} for environment ${environmentId}`
@@ -91,11 +87,12 @@ export const updateWebhook = async (
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
validateInputs([id, ZId]);
try {
return await prisma.webhook.delete({
let deletedWebhook = await prisma.webhook.delete({
where: {
id,
},
});
return deletedWebhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
throw new ResourceNotFoundError("Webhook", id);
+2
View File
@@ -7,6 +7,7 @@ export const ZWebhook = z.object({
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
source: z.enum(["user", "zapier"]),
environmentId: z.string().cuid2(),
triggers: z.array(ZPipelineTrigger),
surveyIds: z.array(z.string().cuid2()),
@@ -18,6 +19,7 @@ export const ZWebhookInput = z.object({
url: z.string().url(),
name: z.string().nullish(),
triggers: z.array(ZPipelineTrigger),
source: z.enum(["user", "zapier"]).optional(),
surveyIds: z.array(z.string().cuid2()).optional(),
});