mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
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:
@@ -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=
|
||||
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
73
apps/web/app/api/v1/integrations/airtable/callback/route.ts
Normal file
73
apps/web/app/api/v1/integrations/airtable/callback/route.ts
Normal 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 });
|
||||
}
|
||||
55
apps/web/app/api/v1/integrations/airtable/route.ts
Normal file
55
apps/web/app/api/v1/integrations/airtable/route.ts
Normal 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 });
|
||||
}
|
||||
44
apps/web/app/api/v1/integrations/airtable/tables/route.ts
Normal file
44
apps/web/app/api/v1/integrations/airtable/tables/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IntegrationType" ADD VALUE 'airtable';
|
||||
@@ -330,6 +330,7 @@ enum EnvironmentType {
|
||||
|
||||
enum IntegrationType {
|
||||
googleSheets
|
||||
airtable
|
||||
}
|
||||
|
||||
model Integration {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](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).
|
||||
|
||||
250
packages/lib/airtable/service.ts
Normal file
250
packages/lib/airtable/service.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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))]
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
79
packages/types/v1/integration/airtable.ts
Normal file
79
packages/types/v1/integration/airtable.ts
Normal 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>;
|
||||
60
packages/types/v1/integration/googleSheet.ts
Normal file
60
packages/types/v1/integration/googleSheet.ts
Normal 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>;
|
||||
39
packages/types/v1/integration/index.ts
Normal file
39
packages/types/v1/integration/index.ts
Normal 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>;
|
||||
15
packages/types/v1/integration/sharedTypes.ts
Normal file
15
packages/types/v1/integration/sharedTypes.ts
Normal 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(),
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user