feat: add airtable integration (#926)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Nafees Nazik
2023-10-20 00:24:10 +05:30
committed by GitHub
parent f7f271b4b3
commit 18d1a23cfd
40 changed files with 1543 additions and 180 deletions

View File

@@ -114,4 +114,12 @@ NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
GOOGLE_SHEETS_CLIENT_SECRET=
GOOGLE_SHEETS_REDIRECT_URL=
# Oauth credentials for Airtable integration
AIR_TABLE_CLIENT_ID=
*/

View File

@@ -0,0 +1,147 @@
"use client";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui/Button";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import AddIntegrationModal, {
IntegrationModalInputs,
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/airtable/actions";
import { useState } from "react";
import { toast } from "react-hot-toast";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
interface handleModalProps {
airtableIntegration: TIntegrationAirtable;
environment: TEnvironment;
environmentId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
}
const tableHeaders = ["Survey", "Table Name", "Questions", "Updated At"];
export default function Home(props: handleModalProps) {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
null
);
const [isModalOpen, setIsModalOpen] = useState(false);
const integrationData = airtableIntegration?.config?.data ?? [];
const handleDeleteIntegration = async () => {
try {
setisDeleting(true);
await deleteIntegrationAction(airtableIntegration.id);
setIsConnected(false);
toast.success("Integration removed successfully");
} catch (error) {
toast.error(error.message);
} finally {
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
}
};
const handleModal = (val: boolean) => {
setIsModalOpen(val);
};
const data = defaultValues
? { isEditMode: true as const, defaultData: defaultValues }
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className=" flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span
className="cursor-pointer text-slate-500"
onClick={() => {
setIsDeleteIntegrationModalOpen(true);
}}>
Connected with {airtableIntegration.config.email}
</span>
</div>
<Button
onClick={() => {
setDefaultValues(null);
handleModal(true);
}}
variant="darkCTA">
Link new table
</Button>
</div>
{integrationData.length ? (
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header, idx) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
{header}
</div>
))}
</div>
{integrationData.map((data, index) => (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
setDefaultValues({
base: data.baseId,
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
index,
});
setIsModalOpen(true);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString())}</div>
</div>
))}
</div>
) : (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage="Your airtable integrations will appear here as soon as you add them. ⏲️"
/>
</div>
)}
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat="airtable connection"
onDelete={handleDeleteIntegration}
text="Are you sure? Your integrations will break."
isDeleting={isDeleting}
/>
{isModalOpen && (
<AddIntegrationModal
airtableArray={airtableArray}
open={isModalOpen}
setOpenWithStates={handleModal}
environmentId={environmentId}
surveys={surveys}
airtableIntegration={airtableIntegration}
{...data}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
"use server";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
import { TIntegrationInput } from "@formbricks/types/v1/integration";
export async function upsertIntegrationAction(environmentId: string, integrationData: TIntegrationInput) {
return await createOrUpdateIntegration(environmentId, integrationData);
}
export async function deleteIntegrationAction(integrationId: string) {
return await deleteIntegration(integrationId);
}
export async function refreshTablesAction(environmentId: string) {
return await getAirtableTables(environmentId);
}

View File

@@ -0,0 +1,391 @@
"use client";
import {
TIntegrationAirtableTables,
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableInput,
} from "@formbricks/types/v1/integration/airtable";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import AirtableLogo from "../images/airtable.svg";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Control, Controller, UseFormSetValue, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { upsertIntegrationAction } from "../actions";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
type EditModeProps =
| { isEditMode: false; defaultData?: never }
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
type AddIntegrationModalProps = {
open: boolean;
setOpenWithStates: (v: boolean) => void;
environmentId: string;
airtableArray: TIntegrationItem[];
surveys: TSurvey[];
airtableIntegration: TIntegrationAirtable;
} & EditModeProps;
export type IntegrationModalInputs = {
base: string;
table: string;
survey: string;
questions: string[];
};
function NoBaseFoundError() {
return (
<Alert>
<AlertTitle>No Airbase bases found</AlertTitle>
<AlertDescription>create a Airbase base</AlertDescription>
</Alert>
);
}
interface BaseSelectProps {
control: Control<IntegrationModalInputs, any>;
isLoading: boolean;
fetchTable: (val: string) => Promise<void>;
airtableArray: TIntegrationItem[];
setValue: UseFormSetValue<IntegrationModalInputs>;
defaultValue: string | undefined;
}
function BaseSelect({
airtableArray,
control,
fetchTable,
isLoading,
setValue,
defaultValue,
}: BaseSelectProps) {
return (
<div className="flex w-full flex-col">
<Label htmlFor="base">Airtable base</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="base"
render={({ field }) => (
<Select
required
disabled={isLoading}
onValueChange={async (val) => {
field.onChange(val);
await fetchTable(val);
setValue("table", "");
}}
defaultValue={defaultValue}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{airtableArray.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
);
}
export default function AddIntegrationModal(props: AddIntegrationModalProps) {
const {
open,
setOpenWithStates,
environmentId,
airtableArray,
surveys,
airtableIntegration,
isEditMode,
defaultData,
} = props;
const router = useRouter();
const [tables, setTables] = useState<TIntegrationAirtableTables["tables"]>([]);
const [isLoading, setIsLoading] = useState(false);
const { handleSubmit, control, watch, setValue, reset } = useForm<IntegrationModalInputs>();
useEffect(() => {
if (isEditMode) {
const { index: _index, ...rest } = defaultData;
reset(rest);
fetchTable(defaultData.base);
} else {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode]);
const survey = watch("survey");
const selectedSurvey = surveys.find((item) => item.id === survey);
const submitHandler = async (data: IntegrationModalInputs) => {
try {
if (!data.base || data.base === "") {
throw new Error("Please select a base");
}
if (!data.table || data.table === "") {
throw new Error("Please select a table");
}
if (!selectedSurvey) {
throw new Error("Please select a survey");
}
if (data.questions.length === 0) {
throw new Error("Please select at least one question");
}
const airtableIntegrationData: TIntegrationAirtableInput = {
type: "airtable",
config: {
key: airtableIntegration?.config?.key,
data: airtableIntegration.config.data ?? [],
email: airtableIntegration?.config?.email,
},
};
const currentTable = tables.find((item) => item.id === data.table);
const integrationData: TIntegrationAirtableConfigData = {
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
questionIds: data.questions,
questions:
data.questions.length === selectedSurvey.questions.length ? "All questions" : "Selected questions",
createdAt: new Date(),
baseId: data.base,
tableId: data.table,
tableName: currentTable?.name ?? "",
};
if (isEditMode) {
// update action
airtableIntegrationData.config!.data[defaultData.index] = integrationData;
} else {
// create action
airtableIntegrationData.config?.data.push(integrationData);
}
const actionMessage = isEditMode ? "updated" : "added";
await upsertIntegrationAction(environmentId, airtableIntegrationData);
toast.success(`Integration ${actionMessage} successfully`);
handleClose();
} catch (e) {
toast.error(e.message);
}
};
const handleTable = async (baseId: string) => {
const data = await fetchTables(environmentId, baseId);
if (data.tables) {
setTables(data.tables);
}
};
const fetchTable = async (val: string) => {
setIsLoading(true);
await handleTable(val);
setIsLoading(false);
};
const handleClose = () => {
reset();
setOpenWithStates(false);
};
const handleDelete = async (index: number) => {
try {
const integrationCopy = { ...airtableIntegration };
integrationCopy.config.data.splice(index, 1);
await upsertIntegrationAction(environmentId, integrationCopy);
handleClose();
router.refresh();
toast.success(`Integration deleted successfully`);
} catch (e) {
toast.error(e.message);
}
};
return (
<Modal open={open} setOpen={handleClose} noPadding>
<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">
<Image className="w-12" src={AirtableLogo} alt="Airbase logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Link Airbase Table</div>
<div className="text-sm text-slate-500">Sync responses with a Airbase table</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="flex rounded-lg p-6">
<div className="flex w-full flex-col gap-y-4 pt-5">
{airtableArray.length ? (
<BaseSelect
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">Table</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">Select Survey</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
render={({ field }) => (
<Select
required
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
) : null}
{!surveys.length ? (
<p className="m-1 text-xs text-slate-500">
You have to create a survey to be able to setup this integration
</p>
) : null}
{survey && selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<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">
{selectedSurvey?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{question.headline}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="warn">
Delete
</Button>
) : (
<Button type="button" loading={isLoading} variant="minimal" onClick={handleClose}>
Cancel
</Button>
)}
<Button variant="darkCTA" type="submit">
Save
</Button>
</div>
</div>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import Connect from "./Connect";
import Home from "../Home";
import { useState } from "react";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
interface AirtableWrapperProps {
environmentId: string;
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
environment: TEnvironment;
enabled: boolean;
webAppUrl: string;
}
export default function AirtableWrapper({
environmentId,
airtableArray,
airtableIntegration,
surveys,
environment,
enabled,
webAppUrl,
}: AirtableWrapperProps) {
const [isConnected, setIsConnected_] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
);
const setIsConnected = (data: boolean) => {
setIsConnected_(data);
};
return isConnected && airtableIntegration ? (
<Home
airtableArray={airtableArray}
environmentId={environmentId}
environment={environment}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}
/>
) : (
<Connect enabled={enabled} environmentId={environment.id} webAppUrl={webAppUrl} />
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import FormbricksLogo from "@/images/logo.svg";
import { Button } from "@formbricks/ui/Button";
import Image from "next/image";
import { useState } from "react";
import AirtableLogo from "../images/airtable.svg";
interface AirtableConnectProps {
enabled: boolean;
environmentId: string;
webAppUrl: string;
}
export default function AirtableConnect({ environmentId, enabled, webAppUrl }: AirtableConnectProps) {
const [isConnecting, setIsConnecting] = useState(false);
const handleGoogleLogin = async () => {
setIsConnecting(true);
authorize(environmentId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
});
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={FormbricksLogo} alt="Formbricks Logo" />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={AirtableLogo} alt="Airtable Logo" />
</div>
</div>
<p className="my-8">Sync responses directly with Airtable.</p>
{!enabled && (
<p className="mb-8 rounded border-gray-200 bg-gray-100 p-3 text-sm">
Airtable Integration is not configured in your instance of Formbricks.
</p>
)}
<Button variant="darkCTA" loading={isConnecting} onClick={handleGoogleLogin} disabled={!enabled}>
Connect with Airtable
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -20.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M114.25873,2.70101695 L18.8604023,42.1756384 C13.5552723,44.3711638 13.6102328,51.9065311 18.9486282,54.0225085 L114.746142,92.0117514 C123.163769,95.3498757 132.537419,95.3498757 140.9536,92.0117514 L236.75256,54.0225085 C242.08951,51.9065311 242.145916,44.3711638 236.83934,42.1756384 L141.442459,2.70101695 C132.738459,-0.900338983 122.961284,-0.900338983 114.25873,2.70101695" fill="#FFBF00">
</path>
<path d="M136.349071,112.756863 L136.349071,207.659101 C136.349071,212.173089 140.900664,215.263892 145.096461,213.600615 L251.844122,172.166219 C254.281184,171.200072 255.879376,168.845451 255.879376,166.224705 L255.879376,71.3224678 C255.879376,66.8084791 251.327783,63.7176768 247.131986,65.3809537 L140.384325,106.815349 C137.94871,107.781496 136.349071,110.136118 136.349071,112.756863" fill="#26B5F8">
</path>
<path d="M111.422771,117.65355 L79.742409,132.949912 L76.5257763,134.504714 L9.65047684,166.548104 C5.4112904,168.593211 0.000578531073,165.503855 0.000578531073,160.794612 L0.000578531073,71.7210757 C0.000578531073,70.0173017 0.874160452,68.5463864 2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill="#ED3049">
</path>
<path d="M111.422771,117.65355 L79.742409,132.949912 L2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill-opacity="0.25" fill="#000000">

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,26 @@
import { TIntegrationAirtableTables } from "@formbricks/types/v1/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => {
const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
cache: "no-store",
});
return res.json() as Promise<TIntegrationAirtableTables>;
};
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
if (!res.ok) {
console.error(res.text);
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.authUrl;
return authUrl;
};

View File

@@ -0,0 +1,47 @@
import AirtableWrapper from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default async function Airtable({ params }) {
const enabled = !!AIR_TABLE_CLIENT_ID;
const [surveys, integrations, environment] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
);
let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration && airtableIntegration.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
return (
<>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<div className="h-[75vh] w-full">
<AirtableWrapper
enabled={enabled}
airtableIntegration={airtableIntegration}
airtableArray={airtableArray}
environmentId={environment.id}
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
/>
</div>
</>
);
}

View File

@@ -1,15 +1,19 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { getSpreadSheets } from "@formbricks/lib/googleSheet/service";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
import { TIntegrationInput } from "@formbricks/types/v1/integrations";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessIntegration } from "@formbricks/lib/integration/auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { TIntegrationGoogleSheetsInput } from "@formbricks/types/v1/integration/googleSheet";
export async function upsertIntegrationAction(environmentId: string, integrationData: TIntegrationInput) {
export async function createOrUpdateIntegrationAction(
environmentId: string,
integrationData: TIntegrationGoogleSheetsInput
) {
return await createOrUpdateIntegration(environmentId, integrationData);
}

View File

@@ -1,31 +1,31 @@
import { TSurvey } from "@formbricks/types/v1/surveys";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import {
TGoogleSheetIntegration,
TGoogleSheetsConfigData,
TGoogleSpreadsheet,
TIntegrationInput,
} from "@formbricks/types/v1/integrations";
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
TIntegrationGoogleSheetsInput,
} from "@formbricks/types/v1/integration/googleSheet";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { Checkbox } from "@formbricks/ui/Checkbox";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import { useState, useEffect } from "react";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Image from "next/image";
import { Modal } from "@formbricks/ui/Modal";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { upsertIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import GoogleSheetLogo from "../images/google-sheets-small.png";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
interface AddWebhookModalProps {
environmentId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
spreadsheets: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration;
selectedIntegration?: (TGoogleSheetsConfigData & { index: number }) | null;
spreadsheets: TIntegrationItem[];
googleSheetIntegration: TIntegrationGoogleSheets;
selectedIntegration?: (TIntegrationGoogleSheetsConfigData & { index: number }) | null;
}
export default function AddIntegrationModal({
@@ -55,7 +55,7 @@ export default function AddIntegrationModal({
const [selectedSpreadsheet, setSelectedSpreadsheet] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState<any>(null);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const googleSheetIntegrationData: TIntegrationInput = {
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
type: "googleSheets",
config: {
key: googleSheetIntegration?.config?.key,
@@ -120,7 +120,7 @@ export default function AddIntegrationModal({
// create action
googleSheetIntegrationData.config!.data.push(integrationData);
}
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
await createOrUpdateIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success(`Integration ${selectedIntegration ? "updated" : "added"} successfully`);
resetForm();
setOpen(false);
@@ -153,7 +153,7 @@ export default function AddIntegrationModal({
googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await upsertIntegrationAction(environmentId, googleSheetIntegrationData);
await createOrUpdateIntegrationAction(environmentId, googleSheetIntegrationData);
toast.success("Integration removed successfully");
setOpen(false);
} catch (error) {

View File

@@ -1,6 +1,6 @@
"use client";
import GoogleSheetLogo from "@/images/google-sheets-small.png";
import GoogleSheetLogo from "../images/google-sheets-small.png";
import FormbricksLogo from "@/images/logo.svg";
import { authorize } from "../lib/google";
import { Button } from "@formbricks/ui/Button";

View File

@@ -1,24 +1,24 @@
"use client";
import { useState } from "react";
import Home from "./Home";
import Connect from "./Connect";
import AddIntegrationModal from "./AddIntegrationModal";
import {
TGoogleSheetIntegration,
TGoogleSheetsConfigData,
TGoogleSpreadsheet,
} from "@formbricks/types/v1/integrations";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { refreshSheetAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/v1/integration/googleSheet";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { useState } from "react";
import AddIntegrationModal from "./AddIntegrationModal";
import Connect from "./Connect";
import Home from "./Home";
interface GoogleSheetWrapperProps {
enabled: boolean;
environment: TEnvironment;
surveys: TSurvey[];
spreadSheetArray: TGoogleSpreadsheet[];
googleSheetIntegration: TGoogleSheetIntegration | undefined;
spreadSheetArray: TIntegrationItem[];
googleSheetIntegration?: TIntegrationGoogleSheets;
webAppUrl: string;
}
@@ -36,7 +36,7 @@ export default function GoogleSheetWrapper({
const [spreadsheets, setSpreadsheets] = useState(spreadSheetArray);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TGoogleSheetsConfigData & { index: number }) | null
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const refreshSheet = async () => {

View File

@@ -1,21 +1,24 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TGoogleSheetIntegration, TGoogleSheetsConfigData } from "@formbricks/types/v1/integrations";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/v1/integration/googleSheet";
import { Button } from "@formbricks/ui/Button";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { useState } from "react";
import toast from "react-hot-toast";
interface HomeProps {
environment: TEnvironment;
googleSheetIntegration: TGoogleSheetIntegration;
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TGoogleSheetsConfigData & { index: number }) | null) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
refreshSheet: () => void;
}

View File

@@ -1,16 +1,17 @@
import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import GoBackButton from "@formbricks/ui/GoBackButton";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getSpreadSheets } from "@formbricks/lib/googleSheet/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
import {
GOOGLE_SHEETS_CLIENT_ID,
WEBAPP_URL,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
import { TIntegrationGoogleSheets } from "@formbricks/types/v1/integration/googleSheet";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default async function GoogleSheet({ params }) {
const enabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
@@ -23,10 +24,10 @@ export default async function GoogleSheet({ params }) {
throw new Error("Environment not found");
}
const googleSheetIntegration: TGoogleSheetIntegration | undefined = integrations?.find(
(integration): integration is TGoogleSheetIntegration => integration.type === "googleSheets"
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
let spreadSheetArray: TGoogleSpreadsheet[] = [];
let spreadSheetArray: TIntegrationItem[] = [];
if (googleSheetIntegration && googleSheetIntegration.config.key) {
spreadSheetArray = await getSpreadSheets(params.environmentId);
}

View File

@@ -1,9 +1,10 @@
import AirtableLogo from "./airtable/images/airtable.svg";
import GoogleSheetsLogo from "./google-sheets/images/google-sheets-small.png";
import JsLogo from "@/images/jslogo.png";
import MakeLogo from "@/images/make-small.png";
import n8nLogo from "@/images/n8n.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import GoogleSheetsLogo from "@/images/google-sheets-small.png";
import n8nLogo from "@/images/n8n.png";
import MakeLogo from "@/images/make-small.png";
import { Card } from "@formbricks/ui/Card";
import Image from "next/image";
import { getCountOfWebhooksBasedOnSource } from "@formbricks/lib/webhook/service";
@@ -24,6 +25,8 @@ export default async function IntegrationsPage({ params }) {
(integration) => integration.type === "googleSheets"
);
const containsAirtableIntegration = integrations.some((integration) => integration.type === "airtable");
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/getting-started/framework-guides#next-js",
@@ -76,6 +79,19 @@ export default async function IntegrationsPage({ params }) {
connected: containsGoogleSheetIntegration ? true : false,
statusText: containsGoogleSheetIntegration ? "Connected" : "Not Connected",
},
{
connectHref: `/environments/${params.environmentId}/integrations/airtable`,
connectText: `${containsAirtableIntegration ? "Manage Table" : "Connect"}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/integrations/airtable",
docsText: "Docs",
docsNewTab: true,
label: "Airtable",
description: "Instantly populate your airtable table with survey data",
icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
connected: containsAirtableIntegration ? true : false,
statusText: containsAirtableIntegration ? "Connected" : "Not Connected",
},
{
docsHref: "https://formbricks.com/docs/integrations/n8n",
docsText: "Docs",

View File

@@ -1,19 +1,37 @@
import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service";
import { TIntegration } from "@formbricks/types/v1/integration";
import { writeData } from "@formbricks/lib/googleSheet/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { TGoogleSheetIntegration, TIntegration } from "@formbricks/types/v1/integrations";
import { TPipelineInput } from "@formbricks/types/v1/pipelines";
import { TIntegrationGoogleSheets } from "@formbricks/types/v1/integration/googleSheet";
import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable";
export async function handleIntegrations(integrations: TIntegration[], data: TPipelineInput) {
for (const integration of integrations) {
switch (integration.type) {
case "googleSheets":
await handleGoogleSheetsIntegration(integration as TGoogleSheetIntegration, data);
await handleGoogleSheetsIntegration(integration as TIntegrationGoogleSheets, data);
break;
case "airtable":
await handleAirtableIntegration(integration as TIntegrationAirtable, data);
break;
}
}
}
async function handleGoogleSheetsIntegration(integration: TGoogleSheetIntegration, data: TPipelineInput) {
async function handleAirtableIntegration(integration: TIntegrationAirtable, data: TPipelineInput) {
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const values = await extractResponses(data, element.questionIds);
await airtableWriteData(integration.config.key, element, values);
}
}
}
}
async function handleGoogleSheetsIntegration(integration: TIntegrationGoogleSheets, data: TPipelineInput) {
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {

View File

@@ -0,0 +1,73 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { connectAirtable, fetchAirtableAuthToken } from "@formbricks/lib/airtable/service";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import * as z from "zod";
async function getEmail(token: string) {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const res_ = await req_.json();
return z.string().parse(res_?.email);
}
export async function GET(req: NextRequest) {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}
const client_id = AIR_TABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 });
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
try {
const key = await fetchAirtableAuthToken(formData);
const email = await getEmail(key.access_token);
await connectAirtable({
environmentId,
email,
key,
});
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {}
NextResponse.json({ Error: "unknown error occurred" }, { status: 400 });
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import crypto from "crypto";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
export async function GET(req: NextRequest) {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}
const client_id = AIR_TABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 });
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
return NextResponse.json({ authUrl: authUrl.toString() }, { status: 200 });
}

View File

@@ -0,0 +1,44 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getTables } from "@formbricks/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { TIntegrationAirtable } from "@formbricks/types/v1/integration/airtable";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import * as z from "zod";
export async function GET(req: NextRequest) {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const session = await getServerSession(authOptions);
const baseId = z.string().safeParse(queryParams.get("baseId"));
if (!baseId.success) {
return NextResponse.json({ Error: "Base Id is Required" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment || !environmentId) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
console.log(integration);
if (!integration) {
return NextResponse.json({ Error: "integration not found" }, { status: 401 });
}
const tables = await getTables(integration.config.key, baseId.data);
return NextResponse.json(tables, { status: 200 });
}

View File

@@ -1,4 +1,4 @@
import { Question } from "@/../../packages/types/questions";
import { Question } from "@formbricks/types/questions";
import { TTemplate } from "@formbricks/types/v1/templates";
export const replaceQuestionPresetPlaceholders = (question: Question, product) => {

View File

@@ -1,12 +1,12 @@
"use client";
import type { NextPage } from "next";
import { TProduct } from "@/../../packages/types/v1/product";
import { TResponse } from "@/../../packages/types/v1/responses";
import { TProduct } from "@formbricks/types/v1/product";
import { TResponse } from "@formbricks/types/v1/responses";
import { OTPInput } from "@formbricks/ui/OTPInput";
import { useCallback, useEffect, useState } from "react";
import { validateSurveyPin } from "@/app/s/[surveyId]/actions";
import { TSurvey } from "@/../../packages/types/v1/surveys";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import { cn } from "@formbricks/lib/cn";

View File

@@ -54,6 +54,9 @@ export const env = createEnv({
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
AIR_TABLE_CLIENT_ID: z.string().optional(),
AWS_ACCESS_KEY: z.string().optional(),
AWS_SECRET_KEY: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
@@ -130,5 +133,6 @@ export const env = createEnv({
SURVEY_BASE_URL: process.env.SURVEY_BASE_URL,
SHORT_SURVEY_BASE_URL: process.env.SHORT_SURVEY_BASE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
AIR_TABLE_CLIENT_ID: process.env.AIR_TABLE_CLIENT_ID,
},
});

View File

@@ -1,5 +1,5 @@
import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/v1/integrations";
import { TIntegrationConfig } from "@formbricks/types/v1/integration";
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/v1/responses";
import {
TSurveyWelcomeCard,

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IntegrationType" ADD VALUE 'airtable';

View File

@@ -330,6 +330,7 @@ enum EnvironmentType {
enum IntegrationType {
googleSheets
airtable
}
model Integration {

View File

@@ -2,7 +2,7 @@ import z from "zod";
export const ZEventProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
export { ZIntegrationConfig } from "@formbricks/types/v1/integrations";
export { ZIntegrationConfig } from "@formbricks/types/v1/integration";
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";

View File

@@ -4,7 +4,7 @@
[![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT)
Please see [Formbricks Docs](https://formbricks.com/docs).
Specifically, [Quickstart/Implementation details](https://formbricks.com/docs/getting-started/quickstart).
Specifically, [Quickstart/Implementation details](https://formbricks.com/docs/getting-started/quickstart-in-app-survey).
## What is Formbricks
@@ -33,4 +33,4 @@ if (typeof window !== "undefined") {
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Setup Checklist** in the Formbricks settings.
For more detailed guides for different frameworks, check out our [Next.js](https://formbricks.com/docs/getting-started/nextjs) and [Vue.js](https://formbricks.com/docs/getting-started/vuejs) guides.
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

View File

@@ -0,0 +1,250 @@
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/v1/errors";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
TIntegrationAirtableInput,
ZIntegrationAirtableBases,
ZIntegrationAirtableCredential,
ZIntegrationAirtableTables,
ZIntegrationAirtableTablesWithFields,
ZIntegrationAirtableTokenSchema,
} from "@formbricks/types/v1/integration/airtable";
import { Prisma } from "@prisma/client";
import { AIR_TABLE_CLIENT_ID } from "../constants";
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
interface ConnectAirtableOptions {
environmentId: string;
key: TIntegrationAirtableCredential;
email: string;
}
export const connectAirtable = async ({ email, environmentId, key }: ConnectAirtableOptions) => {
const type: TIntegrationAirtableInput["type"] = "airtable";
const baseData: TIntegrationAirtableInput = {
type,
config: { data: [], key, email },
};
await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type,
},
},
update: {
...baseData,
environment: { connect: { id: environmentId } },
},
create: {
...baseData,
environment: { connect: { id: environmentId } },
},
});
};
export const getBases = async (key: string) => {
const req = await fetch("https://api.airtable.com/v0/meta/bases", {
headers: {
Authorization: `Bearer ${key}`,
},
});
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string) => {
const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables`, {
headers: {
Authorization: `Bearer ${key.access_token}`,
},
});
const res = await req.json();
return res;
};
export const getTables = async (key: TIntegrationAirtableCredential, baseId: string) => {
const res = await tableFetcher(key, baseId);
return ZIntegrationAirtableTables.parse(res);
};
export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
const formBody = Object.keys(formData)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(formData[key])}`)
.join("&");
const tokenReq = await fetch("https://airtable.com/oauth2/v1/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formBody,
method: "POST",
});
const tokenRes: unknown = await tokenReq.json();
const { access_token, expires_in, refresh_token } = ZIntegrationAirtableTokenSchema.parse(tokenRes);
const expiry_date = new Date();
expiry_date.setSeconds(expiry_date.getSeconds() + expires_in);
return {
access_token,
expiry_date: expiry_date.toISOString(),
refresh_token,
};
};
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
);
const expiryDate = new Date(expiry_date);
const currentDate = new Date();
if (currentDate >= expiryDate) {
const client_id = AIR_TABLE_CLIENT_ID;
const newToken = await fetchAirtableAuthToken({
grant_type: "refresh_token",
refresh_token,
client_id,
});
await createOrUpdateIntegration(environmentId, {
type: "airtable",
config: {
data: airtableIntegration?.config?.data ?? [],
email: airtableIntegration?.config?.email ?? "",
key: newToken,
},
});
return newToken.access_token;
}
return access_token;
} catch (error) {
await deleteIntegration(environmentId);
throw new Error("invalid token");
}
};
export const getAirtableTables = async (environmentId: string) => {
let tables: TIntegrationItem[] = [];
try {
const token = await getAirtableToken(environmentId);
tables = (await getBases(token)).bases;
return tables;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
const addRecords = async (
key: TIntegrationAirtableCredential,
baseId: string,
tableId: string,
data: Record<string, string>
) => {
const req = await fetch(`https://api.airtable.com/v0/${baseId}/${tableId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${key.access_token}`,
"Content-type": "application/json",
},
body: JSON.stringify({
fields: data,
typecast: true,
}),
});
return await req.json();
};
const addField = async (
key: TIntegrationAirtableCredential,
baseId: string,
tableId: string,
data: Record<string, string>
) => {
const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables/${tableId}/fields`, {
method: "POST",
headers: {
Authorization: `Bearer ${key.access_token}`,
"Content-type": "application/json",
},
body: JSON.stringify(data),
});
return await req.json();
};
export const writeData = async (
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
values: string[][]
) => {
try {
const responses = values[0];
const questions = values[1];
const data: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
data[questions[i]] = responses[i];
}
const req = await tableFetcher(key, configData.baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
const currentTable = tables.find((table) => table.id === configData.tableId);
if (currentTable) {
const currentFields = new Set(currentTable.fields.map((field) => field.name));
const fieldsToCreate = new Set<string>();
for (const field of questions) {
const hasField = currentFields.has(field);
if (!hasField) {
fieldsToCreate.add(field);
}
}
if (fieldsToCreate.size > 0) {
const createFieldPromise: Promise<any>[] = [];
fieldsToCreate.forEach((fieldName) => {
createFieldPromise.push(
addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
})
);
});
await Promise.all(createFieldPromise);
}
}
await addRecords(key, configData.baseId, configData.tableId, data);
} catch (error: any) {
console.error(error?.message);
}
};

View File

@@ -48,6 +48,8 @@ export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL;
export const AIR_TABLE_CLIENT_ID = env.AIR_TABLE_CLIENT_ID;
export const SMTP_HOST = env.SMTP_HOST;
export const SMTP_PORT = env.SMTP_PORT;
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1";

View File

@@ -1,23 +1,23 @@
import "server-only";
import { z } from "zod";
import { validateInputs } from "../utils/validate";
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/v1/errors";
import { ZString } from "@formbricks/types/v1/common";
import { ZId } from "@formbricks/types/v1/environment";
import { DatabaseError, UnknownError } from "@formbricks/types/v1/errors";
import { TIntegrationItem } from "@formbricks/types/v1/integration";
import {
ZGoogleCredential,
TGoogleCredential,
TGoogleSpreadsheet,
TGoogleSheetIntegration,
} from "@formbricks/types/v1/integrations";
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
ZIntegrationGoogleSheetsCredential,
} from "@formbricks/types/v1/integration/googleSheet";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "../constants";
import { ZString } from "@formbricks/types/v1/common";
import { getIntegrationByType } from "../integration/service";
import { validateInputs } from "../utils/validate";
const { google } = require("googleapis");
@@ -35,15 +35,15 @@ async function fetchSpreadsheets(auth: any) {
}
}
export const getSpreadSheets = async (environmentId: string): Promise<TGoogleSpreadsheet[]> => {
export const getSpreadSheets = async (environmentId: string): Promise<TIntegrationItem[]> => {
validateInputs([environmentId, ZId]);
let spreadsheets: TGoogleSpreadsheet[] = [];
let spreadsheets: TIntegrationItem[] = [];
try {
const googleIntegration = (await getIntegrationByType(
environmentId,
"googleSheets"
)) as TGoogleSheetIntegration;
)) as TIntegrationGoogleSheets;
if (googleIntegration && googleIntegration.config?.key) {
spreadsheets = await fetchSpreadsheets(googleIntegration.config?.key);
}
@@ -55,9 +55,13 @@ export const getSpreadSheets = async (environmentId: string): Promise<TGoogleSpr
throw error;
}
};
export async function writeData(credentials: TGoogleCredential, spreadsheetId: string, values: string[][]) {
export async function writeData(
credentials: TIntegrationGoogleSheetsCredential,
spreadsheetId: string,
values: string[][]
) {
validateInputs(
[credentials, ZGoogleCredential],
[credentials, ZIntegrationGoogleSheetsCredential],
[spreadsheetId, ZString],
[values, z.array(z.array(ZString))]
);

View File

@@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { DatabaseError } from "@formbricks/types/v1/errors";
import { ZId } from "@formbricks/types/v1/environment";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/v1/integrations";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/v1/integration";
import { validateInputs } from "../utils/validate";
import { ZString, ZOptionalNumber } from "@formbricks/types/v1/common";
import { ITEMS_PER_PAGE } from "../constants";

View File

@@ -0,0 +1,79 @@
import { z } from "zod";
import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./sharedTypes";
export const ZIntegrationAirtableCredential = z.object({
expiry_date: z.string(),
access_token: z.string(),
refresh_token: z.string(),
});
export type TIntegrationAirtableCredential = z.infer<typeof ZIntegrationAirtableCredential>;
export const ZIntegrationAirtableConfigData = z
.object({
tableId: z.string(),
baseId: z.string(),
tableName: z.string(),
})
.merge(ZIntegrationBaseSurveyData);
export type TIntegrationAirtableConfigData = z.infer<typeof ZIntegrationAirtableConfigData>;
export const ZIntegrationAirtableConfig = z.object({
key: ZIntegrationAirtableCredential,
data: z.array(ZIntegrationAirtableConfigData),
email: z.string(),
});
export type TIntegrationAirtableConfig = z.infer<typeof ZIntegrationAirtableConfig>;
export const ZIntegrationAirtable = ZIntegrationBase.extend({
type: z.literal("airtable"),
config: ZIntegrationAirtableConfig,
});
export type TIntegrationAirtable = z.infer<typeof ZIntegrationAirtable>;
export const ZIntegrationAirtableInput = z.object({
type: z.literal("airtable"),
config: ZIntegrationAirtableConfig,
});
export type TIntegrationAirtableInput = z.infer<typeof ZIntegrationAirtableInput>;
export const ZIntegrationAirtableBases = z.object({
bases: z.array(z.object({ id: z.string(), name: z.string() })),
});
export type TIntegrationAirtableBases = z.infer<typeof ZIntegrationAirtableBases>;
export const ZIntegrationAirtableTables = z.object({
tables: z.array(z.object({ id: z.string(), name: z.string() })),
});
export type TIntegrationAirtableTables = z.infer<typeof ZIntegrationAirtableTables>;
export const ZIntegrationAirtableTokenSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.coerce.number(),
});
export type TIntegrationAirtableTokenSchema = z.infer<typeof ZIntegrationAirtableTokenSchema>;
export const ZIntegrationAirtableTablesWithFields = z.object({
tables: z.array(
z.object({
id: z.string(),
name: z.string(),
fields: z.array(
z.object({
id: z.string(),
name: z.string(),
})
),
})
),
});
export type TIntegrationAirtableTablesWithFields = z.infer<typeof ZIntegrationAirtableTablesWithFields>;

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./sharedTypes";
export const ZGoogleCredential = z.object({
scope: z.string(),
token_type: z.literal("Bearer"),
expiry_date: z.number(),
access_token: z.string(),
refresh_token: z.string(),
});
export type TGoogleCredential = z.infer<typeof ZGoogleCredential>;
export const ZIntegrationGoogleSheetsConfigData = z
.object({
spreadsheetId: z.string(),
spreadsheetName: z.string(),
})
.merge(ZIntegrationBaseSurveyData);
export type TIntegrationGoogleSheetsConfigData = z.infer<typeof ZIntegrationGoogleSheetsConfigData>;
export const ZIntegrationGoogleSheetsConfig = z.object({
key: ZGoogleCredential,
data: z.array(ZIntegrationGoogleSheetsConfigData),
email: z.string(),
});
export type TIntegrationGoogleSheetsConfig = z.infer<typeof ZIntegrationGoogleSheetsConfig>;
export const ZGoogleSheetIntegration = z.object({
id: z.string(),
type: z.literal("googleSheets"),
environmentId: z.string(),
config: ZIntegrationGoogleSheetsConfig,
});
export const ZIntegrationGoogleSheets = ZIntegrationBase.extend({
type: z.literal("googleSheets"),
config: ZIntegrationGoogleSheetsConfig,
});
export type TIntegrationGoogleSheets = z.infer<typeof ZIntegrationGoogleSheets>;
export const ZIntegrationGoogleSheetsInput = z.object({
type: z.literal("googleSheets"),
config: ZIntegrationGoogleSheetsConfig,
});
export type TIntegrationGoogleSheetsInput = z.infer<typeof ZIntegrationGoogleSheetsInput>;
export const ZIntegrationGoogleSheetsCredential = z.object({
scope: z.string(),
token_type: z.literal("Bearer"),
expiry_date: z.number(),
access_token: z.string(),
refresh_token: z.string(),
});
export type TIntegrationGoogleSheetsCredential = z.infer<typeof ZIntegrationGoogleSheetsCredential>;

View File

@@ -0,0 +1,39 @@
import { z } from "zod";
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./googleSheet";
export * from "./sharedTypes";
export const ZIntegrationType = z.enum(["googleSheets", "airtable"]);
export const ZIntegrationConfig = z.union([ZIntegrationGoogleSheetsConfig, ZIntegrationAirtableConfig]);
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
});
export const ZIntegration = ZIntegrationBase.extend({
type: ZIntegrationType,
config: ZIntegrationConfig,
});
export type TIntegration = z.infer<typeof ZIntegration>;
export const ZIntegrationBaseSurveyData = z.object({
createdAt: z.date(),
questionIds: z.array(z.string()),
questions: z.string(),
surveyId: z.string(),
surveyName: z.string(),
});
export const ZIntegrationInput = z.union([ZIntegrationGoogleSheetsInput, ZIntegrationAirtableInput]);
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;
export const ZIntegrationItem = z.object({
name: z.string(),
id: z.string(),
});
export type TIntegrationItem = z.infer<typeof ZIntegrationItem>;

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export * from "./sharedTypes";
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
});
export const ZIntegrationBaseSurveyData = z.object({
createdAt: z.date(),
questionIds: z.array(z.string()),
questions: z.string(),
surveyId: z.string(),
surveyName: z.string(),
});

View File

@@ -1,65 +0,0 @@
import { z } from "zod";
/* GOOGLE SHEETS CONFIGURATIONS */
export const ZGoogleCredential = z.object({
scope: z.string(),
token_type: z.literal("Bearer"),
expiry_date: z.number(),
access_token: z.string(),
refresh_token: z.string(),
});
export type TGoogleCredential = z.infer<typeof ZGoogleCredential>;
export const ZGoogleSpreadsheet = z.object({
name: z.string(),
id: z.string(),
});
export type TGoogleSpreadsheet = z.infer<typeof ZGoogleSpreadsheet>;
export const ZGoogleSheetsConfigData = z.object({
createdAt: z.date(),
questionIds: z.array(z.string()),
questions: z.string(),
spreadsheetId: z.string(),
spreadsheetName: z.string(),
surveyId: z.string(),
surveyName: z.string(),
});
export type TGoogleSheetsConfigData = z.infer<typeof ZGoogleSheetsConfigData>;
const ZGoogleSheetsConfig = z.object({
key: ZGoogleCredential,
data: z.array(ZGoogleSheetsConfigData),
email: z.string(),
});
export type TGoogleSheetsConfig = z.infer<typeof ZGoogleSheetsConfig>;
export const ZGoogleSheetIntegration = z.object({
id: z.string(),
type: z.enum(["googleSheets"]),
environmentId: z.string(),
config: ZGoogleSheetsConfig,
});
export type TGoogleSheetIntegration = z.infer<typeof ZGoogleSheetIntegration>;
// Define a specific schema for integration configs
// When we add other configurations it will be z.union([ZGoogleSheetsConfig, ZSlackConfig, ...])
export const ZIntegrationConfig = ZGoogleSheetsConfig;
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
export const ZIntegrationType = z.enum(["googleSheets"]);
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
export const ZIntegration = z.object({
id: z.string(),
type: ZIntegrationType,
environmentId: z.string(),
config: ZIntegrationConfig,
});
export type TIntegration = z.infer<typeof ZIntegration>;
export const ZIntegrationInput = z.object({
type: ZIntegrationType,
config: ZIntegrationConfig,
});
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;

View File

@@ -1,40 +1,46 @@
"use client";
import { useState } from "react";
import { forwardRef, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
export interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {}
export interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
containerClassName?: string;
}
const PasswordInput = ({ className, ...rest }: PasswordInputProps) => {
const [showPassword, setShowPassword] = useState(false);
const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ className, containerClassName, ...rest }, ref) => {
const [showPassword, setShowPassword] = useState(false);
const togglePasswordVisibility = () => {
setShowPassword((prevShowPassword) => !prevShowPassword);
};
const togglePasswordVisibility = () => {
setShowPassword((prevShowPassword) => !prevShowPassword);
};
return (
<div className={cn("relative", containerClassName)}>
<input
ref={ref}
type={showPassword ? "text" : "password"}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...rest}
/>
<button
type="button"
className={cn("absolute right-3 top-1/2 -translate-y-1/2 transform")}
onClick={togglePasswordVisibility}>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-slate-400 " />
) : (
<EyeIcon className="h-5 w-5 text-slate-400 " />
)}
</button>
</div>
);
}
);
return (
<div className="relative">
<input
type={showPassword ? "text" : "password"}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...rest}
/>
<button
type="button"
className={cn("absolute right-3 top-1/2 -translate-y-1/2 transform")}
onClick={togglePasswordVisibility}>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-slate-400 " />
) : (
<EyeIcon className="h-5 w-5 text-slate-400 " />
)}
</button>
</div>
);
};
PasswordInput.displayName = "PasswordInput";
export { PasswordInput };

View File

@@ -110,6 +110,9 @@
"TELEMETRY_DISABLED",
"VERCEL_URL",
"WEBAPP_URL",
"AIR_TABLE_CLIENT_ID",
"AWS_ACCESS_KEY",
"AWS_SECRET_KEY",
"S3_ACCESS_KEY",
"S3_SECRET_KEY",
"S3_REGION",