mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-04 04:41:38 -05:00
add basic pipeline view to hq
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPipeline, usePipelines } from "@/lib/pipelines";
|
||||
import { webhook } from "./webhook";
|
||||
import { emailNotification } from "./emailNotification";
|
||||
import PipelineSettings from "./PipelineSettings";
|
||||
import Modal from "@/components/Modal";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
const availablePipelines = [webhook, emailNotification];
|
||||
|
||||
const getEmptyPipeline = () => {
|
||||
return { label: "", type: null, events: [], config: {} };
|
||||
};
|
||||
|
||||
export default function AddPipelineModal({ open, setOpen, params }) {
|
||||
const [typeId, setTypeId] = useState(null);
|
||||
const [pipeline, setPipeline] = useState(getEmptyPipeline());
|
||||
const { pipelines, mutatePipelines } = usePipelines(params.formId, params.teamId);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeId !== pipeline.type) {
|
||||
setPipeline({ ...pipeline, type: typeId });
|
||||
}
|
||||
}, [typeId, pipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPipeline(getEmptyPipeline());
|
||||
setTypeId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const newPipeline = await createPipeline(params.formId, params.teamId, pipeline);
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
newPipelines.push(newPipeline);
|
||||
mutatePipelines(newPipelines);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<>
|
||||
{typeId === null ? (
|
||||
<>
|
||||
<h2 className="text-ui-gray-dark mb-6 text-xl font-bold">
|
||||
Please choose a pipeline you want to add
|
||||
</h2>
|
||||
{availablePipelines.map((pipeline) => (
|
||||
<div
|
||||
className="border-ui-gray-light mb-5 w-full border bg-white shadow sm:rounded"
|
||||
key={pipeline.title}>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">{pipeline.title}</h3>
|
||||
<div className="mt-2 sm:flex sm:items-start sm:justify-between">
|
||||
<div className="max-w-xl text-sm text-gray-500">
|
||||
<p>{pipeline.description}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:ml-6 sm:flex sm:flex-shrink-0 sm:items-center">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTypeId(pipeline.typeId);
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
|
||||
<PipelineSettings typeId={typeId} pipeline={pipeline} setPipeline={setPipeline} />
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="ml-2">
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { EmailNotificationSettings } from "./emailNotification";
|
||||
import { WebhookSettings } from "./webhook";
|
||||
|
||||
const PipelineSettings = ({ typeId, pipeline, setPipeline }) => {
|
||||
switch (typeId) {
|
||||
case "WEBHOOK":
|
||||
return <WebhookSettings pipeline={pipeline} setPipeline={setPipeline} />;
|
||||
break;
|
||||
case "EMAIL_NOTIFICATION":
|
||||
return <EmailNotificationSettings pipeline={pipeline} setPipeline={setPipeline} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export default PipelineSettings;
|
||||
@@ -0,0 +1,56 @@
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import Modal from "@/components/Modal";
|
||||
import { persistPipeline, usePipeline, usePipelines } from "@/lib/pipelines";
|
||||
import PipelineSettings from "./PipelineSettings";
|
||||
|
||||
export default function UpdatePipelineModal({ open, setOpen, params, pipelineId }) {
|
||||
const { pipeline, isLoadingPipeline, mutatePipeline } = usePipeline(
|
||||
params.formId,
|
||||
params.teamId,
|
||||
pipelineId
|
||||
);
|
||||
const { pipelines, mutatePipelines } = usePipelines(params.formId, params.teamId);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await persistPipeline(params.formId, params.teamId, pipeline);
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
const pipelineIdx = pipelines.findIndex((p) => p.id === pipelineId);
|
||||
if (pipelineIdx > -1) {
|
||||
newPipelines[pipelineIdx] = pipeline;
|
||||
mutatePipelines(newPipelines);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
{isLoadingPipeline ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
|
||||
<PipelineSettings
|
||||
typeId={pipeline.type}
|
||||
pipeline={pipeline}
|
||||
setPipeline={(p) => mutatePipeline(p, false)}
|
||||
/>
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
export const emailNotification = {
|
||||
typeId: "EMAIL_NOTIFICATION",
|
||||
title: "Email Notification",
|
||||
description: "Get an email notification when your form is completed",
|
||||
};
|
||||
|
||||
const eventTypes = [
|
||||
{
|
||||
id: "PAGE_SUBMISSION",
|
||||
name: "Page Submission",
|
||||
description: "every time a form page is submitted (partial submission)",
|
||||
},
|
||||
{
|
||||
id: "FORM_COMPLETED",
|
||||
name: "Form completed",
|
||||
description: "each time the form is fully completed (total submission)",
|
||||
},
|
||||
];
|
||||
|
||||
export function EmailNotificationSettings({ pipeline, setPipeline }) {
|
||||
const toggleEvent = (eventId) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
const eventIdx = newPipeline.events.indexOf(eventId);
|
||||
if (eventIdx !== -1) {
|
||||
newPipeline.events.splice(eventIdx, 1);
|
||||
} else {
|
||||
newPipeline.events.push(eventId);
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
const updateField = (field, value, parent = null) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
if (parent) {
|
||||
newPipeline[parent][field] = value;
|
||||
} else {
|
||||
newPipeline[field] = value;
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 divide-y divide-gray-200">
|
||||
<div>
|
||||
<h2 className="text-ui-gray-dark mb-3 text-xl font-bold">Configure Email notifications</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure Email notifications. To learn more about how email notifications work, please check out
|
||||
our docs.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Pipeline Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
value={pipeline.name || ""}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="endpointUrl" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
// pattern="/^[^@]+@[^.]+\..+$/"
|
||||
onInvalid={(e: any) => e.target.setCustomValidity("please provide a valid email")}
|
||||
onInput={(e: any) => e.target.setCustomValidity("")}
|
||||
name="email"
|
||||
id="email"
|
||||
value={pipeline.data.email || ""}
|
||||
onChange={(e) => updateField("email", e.target.value, "data")}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500" id="email-description">
|
||||
The email address that will receive notifications when the form/page is completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Advanced Settings</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Set up this webhook to fit your needs.</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Events</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Events
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{eventTypes.map((eventType) => (
|
||||
<div key={eventType.id}>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id={eventType.id}
|
||||
name={eventType.name}
|
||||
type="checkbox"
|
||||
checked={pipeline.events.includes(eventType.id)}
|
||||
onChange={() => toggleEvent(eventType.id)}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor={eventType.id} className="font-medium text-gray-700">
|
||||
{eventType.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{eventType.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Conditions</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Conditions
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-sm border border-gray-100 bg-gray-50 px-2 py-5">
|
||||
<p className="flex justify-center text-xs text-gray-600">
|
||||
conditional data piping coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import EmptyPageFiller from "@/components/EmptyPageFiller";
|
||||
import { useForm } from "@/lib/forms";
|
||||
import { deletePipeline, persistPipeline, usePipelines } from "@/lib/pipelines";
|
||||
import { useTeam } from "@/lib/teams";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { BoltIcon, Cog6ToothIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { CodeBracketSquareIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { AiOutlineMail } from "react-icons/ai";
|
||||
import { SiAirtable, SiGoogle, SiNotion, SiSlack, SiZapier } from "react-icons/si";
|
||||
import AddPipelineModal from "./AddPipelineModal";
|
||||
import UpdatePipelineModal from "./UpdatePipelineModal";
|
||||
|
||||
const integrations = [
|
||||
{
|
||||
id: "webhook",
|
||||
name: "Webhook",
|
||||
href: "#",
|
||||
comingSoon: false,
|
||||
bgColor: "bg-slate-500",
|
||||
icon: CodeBracketSquareIcon,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "email notification",
|
||||
name: "Email Notification",
|
||||
href: "#",
|
||||
comingSoon: false,
|
||||
bgColor: "bg-slate-500",
|
||||
icon: AiOutlineMail,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "Notion",
|
||||
name: "Notion",
|
||||
comingSoon: true,
|
||||
href: "#",
|
||||
bgColor: "bg-slate-500",
|
||||
icon: SiNotion,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "googleSheets",
|
||||
name: "Google Sheets",
|
||||
comingSoon: true,
|
||||
href: "#",
|
||||
bgColor: "bg-slate-500",
|
||||
icon: SiGoogle,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "zapier",
|
||||
name: "Zapier",
|
||||
comingSoon: true,
|
||||
href: "#",
|
||||
bgColor: "bg-slate-500",
|
||||
icon: SiZapier,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "airtable",
|
||||
name: "Airtable",
|
||||
comingSoon: true,
|
||||
href: "#",
|
||||
bgColor: "bg-slate-500",
|
||||
icon: SiAirtable,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
comingSoon: true,
|
||||
href: "#",
|
||||
bgColor: "bg-slate-500",
|
||||
icon: SiSlack,
|
||||
action: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
export default function PipelinesPage({ params }) {
|
||||
const { form, isLoadingForm, isErrorForm } = useForm(params.formId, params.teamId);
|
||||
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
|
||||
const { pipelines, isLoadingPipelines, isErrorPipelines, mutatePipelines } = usePipelines(
|
||||
params.formId,
|
||||
params.teamId
|
||||
);
|
||||
|
||||
if (isLoadingForm || isLoadingTeam) {
|
||||
const [openAddModal, setOpenAddModal] = useState(false);
|
||||
const [updatePipelineId, setUpdatePipelineId] = useState(null);
|
||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||
|
||||
const toggleEnabled = async (pipeline) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
newPipeline.enabled = !newPipeline.enabled;
|
||||
await persistPipeline(params.formId, params.teamId, newPipeline);
|
||||
const pipelineIdx = pipelines.findIndex((p) => p.id === pipeline.id);
|
||||
if (pipelineIdx !== -1) {
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
newPipelines[pipelineIdx] = newPipeline;
|
||||
mutatePipelines(newPipelines);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = (pipeline) => {
|
||||
setUpdatePipelineId(pipeline.id);
|
||||
setOpenUpdateModal(true);
|
||||
};
|
||||
|
||||
const deletePipelineAction = async (pipelineId) => {
|
||||
await deletePipeline(params.formId, params.teamId, pipelineId);
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
const pipelineIdx = newPipelines.findIndex((p) => p.id === pipelineId);
|
||||
if (pipelineIdx > -1) {
|
||||
newPipelines.splice(pipelineIdx, 1);
|
||||
mutatePipelines(newPipelines);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingForm || isLoadingTeam || isLoadingPipelines) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
@@ -16,19 +129,155 @@ export default function PipelinesPage({ params }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorForm || isErrorTeam) {
|
||||
if (isErrorForm || isErrorTeam || isErrorPipelines) {
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
Pipelines - {form.label}
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
Pipelines - {form.label}
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
<Button onClick={() => setOpenAddModal(true)}>Add Pipeline</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="my-4">
|
||||
<p className="text-slate-800">
|
||||
Pipe your data exactly where you need it. Add conditions for variable data piping.
|
||||
</p>
|
||||
</div>
|
||||
{pipelines.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-hidden bg-white shadow sm:rounded-md">
|
||||
<ul role="list" className="divide-y divide-gray-200">
|
||||
{pipelines.map((pipeline) => (
|
||||
<li key={pipeline.id}>
|
||||
<div className="block">
|
||||
<div className="flex items-center px-4 py-4 sm:px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="min-w-0 flex-1 px-4 md:grid md:grid-cols-2 md:gap-4">
|
||||
<div>
|
||||
<p className="truncate text-sm font-medium text-slate-800">{pipeline.label}</p>
|
||||
<p className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<BoltIcon
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{pipeline.type}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div>
|
||||
{<p className="mb-1 text-xs text-slate-800">Active</p>}
|
||||
<Switch
|
||||
checked={pipeline.enabled}
|
||||
onChange={() => toggleEnabled(pipeline)}
|
||||
className={clsx(
|
||||
pipeline.enabled ? "bg-brand-dark" : "bg-gray-200",
|
||||
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
className={clsx(
|
||||
pipeline.enabled ? "translate-x-5" : "translate-x-0",
|
||||
"pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
)}>
|
||||
<span
|
||||
className={clsx(
|
||||
pipeline.enabled
|
||||
? "opacity-0 duration-100 ease-out"
|
||||
: "opacity-100 duration-200 ease-in",
|
||||
"absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
|
||||
)}
|
||||
aria-hidden="true">
|
||||
<svg className="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
pipeline.enabled
|
||||
? "opacity-100 duration-200 ease-in"
|
||||
: "opacity-0 duration-100 ease-out",
|
||||
"absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
|
||||
)}
|
||||
aria-hidden="true">
|
||||
<svg
|
||||
className="text-brand-dark h-3 w-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 12 12">
|
||||
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex">
|
||||
<button onClick={() => openSettings(pipeline)}>
|
||||
<Cog6ToothIcon className="mx-2 h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this pipeline?")) {
|
||||
deletePipelineAction(pipeline.id);
|
||||
}
|
||||
}}>
|
||||
<TrashIcon className="mx-2 h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageFiller
|
||||
alertText={`No active pipelines for '${form.label}'`}
|
||||
hintText="Add a pipeline to get started.">
|
||||
<div className="mx-10 mb-5 grid grid-cols-3 gap-3">
|
||||
{integrations.map((integration) => (
|
||||
<div className="col-span-1 my-1 flex" key={integration.id}>
|
||||
<div className="text-ui-gray-medium relative col-span-1 flex w-full">
|
||||
<integration.icon className="text-ui-gray-medium h-6 w-6" />
|
||||
<div className="inline-flex items-center truncate px-4 text-sm">
|
||||
<p className="">{integration.name}</p>
|
||||
{integration.comingSoon && (
|
||||
<div className="ml-3 rounded-sm border border-teal-100 bg-teal-50 p-0.5 px-2">
|
||||
<p className="text-xs text-teal-600">coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
</EmptyPageFiller>
|
||||
)}
|
||||
<AddPipelineModal open={openAddModal} setOpen={setOpenAddModal} params={params} />
|
||||
{openUpdateModal && (
|
||||
<UpdatePipelineModal
|
||||
open={openUpdateModal}
|
||||
setOpen={setOpenUpdateModal}
|
||||
params={params}
|
||||
pipelineId={updatePipelineId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export const webhook = {
|
||||
typeId: "WEBHOOK",
|
||||
title: "Webhook",
|
||||
description: "Notify an external endpoint when events happen in your form (e.g. a new submission).",
|
||||
};
|
||||
|
||||
const eventTypes = [
|
||||
{
|
||||
id: "SUBMISSION_CREATED",
|
||||
name: "Submission Created",
|
||||
description: "Every time a new submission is created",
|
||||
},
|
||||
];
|
||||
|
||||
export function WebhookSettings({ pipeline, setPipeline }) {
|
||||
const toggleEvent = (eventId) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
const eventIdx = newPipeline.events.indexOf(eventId);
|
||||
if (eventIdx !== -1) {
|
||||
newPipeline.events.splice(eventIdx, 1);
|
||||
} else {
|
||||
newPipeline.events.push(eventId);
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
const updateField = (field, value, parent = null) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
if (parent) {
|
||||
newPipeline[parent][field] = value;
|
||||
} else {
|
||||
newPipeline[field] = value;
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 divide-y divide-gray-200">
|
||||
<div>
|
||||
<h2 className="text-ui-gray-dark mb-3 text-xl font-bold">Configure Webhook</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure your webhook. To learn more about how webhooks work, please check out our docs.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
Webhook Label
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="label"
|
||||
id="label"
|
||||
value={pipeline.label || ""}
|
||||
onChange={(e) => updateField("label", e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-teal-500 focus:ring-teal-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="endpointUrl" className="block text-sm font-medium text-gray-700">
|
||||
Endpoint URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
pattern="^https:\/\/(.*)"
|
||||
onInvalid={(e: any) =>
|
||||
e.target.setCustomValidity("please provide a valid website address with https")
|
||||
}
|
||||
onInput={(e: any) => e.target.setCustomValidity("")}
|
||||
name="endpointUrl"
|
||||
id="endpointUrl"
|
||||
value={pipeline.config.endpointUrl || ""}
|
||||
onChange={(e) => updateField("endpointUrl", e.target.value, "config")}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-teal-500 focus:ring-teal-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500" id="email-description">
|
||||
Your server URL to which the data should be sent (https requiteal)
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="secret" className="block text-sm font-medium text-gray-700">
|
||||
Secret
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="secret"
|
||||
id="secret"
|
||||
value={pipeline.config.secret || ""}
|
||||
onChange={(e) => updateField("secret", e.target.value, "config")}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-teal-500 focus:ring-teal-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500" id="email-description">
|
||||
We sign all event notification payloads with a SHA256 signature using this secret
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Advanced Settings</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Set up this webhook to fit your needs.</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Events</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Events
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{eventTypes.map((eventType) => (
|
||||
<div key={eventType.id}>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="comments"
|
||||
name="comments"
|
||||
type="checkbox"
|
||||
checked={pipeline.events.includes(eventType.id)}
|
||||
onChange={() => toggleEvent(eventType.id)}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="comments" className="font-medium text-gray-700">
|
||||
{eventType.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{eventType.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Conditions</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Conditions
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-sm border border-gray-100 bg-gray-50 px-2 py-5">
|
||||
<p className="flex justify-center text-xs text-gray-600">
|
||||
conditional data piping coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleWebhook(pipeline, event) {
|
||||
if (pipeline.config.hasOwnProperty("endpointUrl") && pipeline.config.hasOwnProperty("secret")) {
|
||||
if (event.type === "pageSubmission" && pipeline.events.includes("PAGE_SUBMISSION")) {
|
||||
const webhookData = pipeline.config;
|
||||
const body = { time: Math.floor(Date.now() / 1000), event };
|
||||
fetch(webhookData.endpointUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": `sha256=${crypto
|
||||
.createHmac("sha256", webhookData.secret.toString())
|
||||
.update(JSON.stringify(body))
|
||||
.digest("base64")}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const usePipelines = (formId: string, teamId: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms/${formId}/pipelines`, fetcher);
|
||||
|
||||
return {
|
||||
pipelines: data,
|
||||
isLoadingPipelines: !error && !data,
|
||||
isErrorPipelines: error,
|
||||
mutatePipelines: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePipeline = (id: string, formId: string, teamId: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms/${formId}/pipelines/${id}`, fetcher);
|
||||
|
||||
return {
|
||||
pipeline: data,
|
||||
isLoadingPipeline: !error && !data,
|
||||
isErrorPipeline: error,
|
||||
mutatePipeline: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const persistPipeline = async (formId, teamId, pipeline) => {
|
||||
try {
|
||||
await fetch(`/api/teams/${teamId}/forms/${formId}/pipelines/${pipeline.id}/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pipeline),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createPipeline = async (formId: string, teamId: string, pipeline = {}) => {
|
||||
try {
|
||||
const res = await fetch(`/api/teams/${teamId}/forms/${formId}/pipelines`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pipeline),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`createPipeline: unable to create pipeline: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePipeline = async (formId: string, teamId: string, pipelineId: string) => {
|
||||
try {
|
||||
await fetch(`/api/teams/${teamId}/forms/${formId}/pipelines/${pipelineId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`deletePipeline: unable to delete pipeline: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -29,9 +29,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
// GET /api/teams[teamId]/forms/[formId]
|
||||
// Get a specific team
|
||||
if (req.method === "GET") {
|
||||
const forms = await prisma.form.findUnique({
|
||||
const forms = await prisma.form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +43,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
// Deletes a single form
|
||||
else if (req.method === "DELETE") {
|
||||
const prismaRes = await prisma.form.delete({
|
||||
where: { id: formId },
|
||||
where: { id: formId, teamId },
|
||||
});
|
||||
return res.json(prismaRes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = req.query.teamId.toString();
|
||||
const formId = req.query.formId.toString();
|
||||
const pipelineId = req.query.pipelineId.toString();
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/forms/[formId]/pipelines/[pipelineId]
|
||||
// Get a specific pipeline
|
||||
if (req.method === "GET") {
|
||||
const forms = await prisma.pipeline.findUnique({
|
||||
where: {
|
||||
id: pipelineId,
|
||||
formId: formId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(forms);
|
||||
}
|
||||
|
||||
// POST /api/teams[teamId]/forms/[formId]/pipelines/[pipelineId]
|
||||
// Replace a specific pipeline
|
||||
else if (req.method === "POST") {
|
||||
const data = { ...req.body, updatedAt: new Date() };
|
||||
const prismaRes = await prisma.pipeline.update({
|
||||
where: { id: pipelineId },
|
||||
data,
|
||||
});
|
||||
return res.json(prismaRes);
|
||||
}
|
||||
|
||||
// Delete /api/teams[teamId]/forms/[formId]/pipelines/[pipelineId]
|
||||
// Deletes a single form
|
||||
else if (req.method === "DELETE") {
|
||||
const prismaRes = await prisma.pipeline.delete({
|
||||
where: { id: pipelineId, formId },
|
||||
});
|
||||
return res.json(prismaRes);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = req.query.teamId.toString();
|
||||
const formId = req.query.formId.toString();
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/forms/[formId]/pipelines
|
||||
// Get pipelines
|
||||
if (req.method === "GET") {
|
||||
// get submission
|
||||
const pipelines = await prisma.pipeline.findMany({
|
||||
where: {
|
||||
form: {
|
||||
id: formId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(pipelines);
|
||||
}
|
||||
|
||||
// POST /api/teams[teamId]/forms/[formId]/pipelines
|
||||
// Create a new pipeline
|
||||
// Required fields in body: name, type
|
||||
// Optional fields in body: enabled, config
|
||||
else if (req.method === "POST") {
|
||||
const pipeline = req.body;
|
||||
|
||||
// create form in db
|
||||
const result = await prisma.pipeline.create({
|
||||
data: {
|
||||
...pipeline,
|
||||
form: { connect: { id: formId } },
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `name` on the `Pipeline` table. All the data in the column will be lost.
|
||||
- Added the required column `label` to the `Pipeline` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PipelineEvent" AS ENUM ('SUBMISSION_CREATED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Pipeline" DROP COLUMN "name",
|
||||
ADD COLUMN "events" "PipelineEvent"[],
|
||||
ADD COLUMN "label" TEXT NOT NULL;
|
||||
@@ -18,16 +18,21 @@ enum PipelineType {
|
||||
EMAIL_NOTIFICATION
|
||||
}
|
||||
|
||||
enum PipelineEvent {
|
||||
SUBMISSION_CREATED
|
||||
}
|
||||
|
||||
model Pipeline {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
label String
|
||||
type PipelineType
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
events PipelineEvent[]
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId String
|
||||
enabled Boolean @default(false)
|
||||
config Json @default("{}")
|
||||
enabled Boolean @default(false)
|
||||
config Json @default("{}")
|
||||
}
|
||||
|
||||
model Customer {
|
||||
|
||||
Reference in New Issue
Block a user