Compare commits

...

32 Commits

Author SHA1 Message Date
harshsbhat
d2343cb60c requested changes 2025-07-22 14:00:36 +05:30
harshsbhat
e10a42f61e Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-22 10:25:43 +05:30
harshsbhat
1319ca648c delete integration button 2025-07-18 10:11:12 +05:30
pandeymangg
f6a544d01e fixes dep resolution with tar-fs 2025-07-16 18:59:47 +05:30
harshsbhat
697c132581 fix: resolve merge conflict in pnpm-lock.yaml 2025-07-16 18:38:23 +05:30
harshsbhat
a53e9a1bee sonar qube issues 2025-07-16 18:35:07 +05:30
harshsbhat
64fd5e40d7 remove console.log 2025-07-16 15:26:21 +05:30
harshsbhat
b3886014cb console.log 2025-07-16 14:49:44 +05:30
harshsbhat
44c5bec535 remove previous changes and reduce duplicate 2025-07-16 13:55:16 +05:30
harshsbhat
416f142385 chore: reduce duplicate code 2025-07-16 12:52:33 +05:30
harshsbhat
d3d9e3223d reduce duplicate code 2025-07-16 12:21:27 +05:30
harshsbhat
130ed59677 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-16 09:40:06 +05:30
harshsbhat
7d8d7fc744 remove console.log 2025-07-16 00:41:50 +05:30
harshsbhat
721ae66811 chore: duplicate code 2025-07-15 22:00:15 +05:30
harshsbhat
af6d9542e4 chore: add more test coverage 2025-07-15 21:25:34 +05:30
harshsbhat
71b408e066 chore: slack build error 2025-07-15 20:37:46 +05:30
harshsbhat
c7277bb709 chore: update the plain connection 2025-07-15 20:14:28 +05:30
harshsbhat
04a709c6c2 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 20:10:07 +05:30
harshsbhat
4ee0b9ec03 chore: add tests 2025-07-15 18:32:16 +05:30
harshsbhat
eb8eac8aa4 lint errors 2025-07-15 17:21:17 +05:30
harshsbhat
c1444f8427 add tests and translations 2025-07-15 16:58:56 +05:30
harshsbhat
15adaf6976 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 15:06:16 +05:30
harshsbhat
85fababd57 add everything in pipeline 2025-07-14 22:49:11 +05:30
harshsbhat
3694f93429 Mege conflicts 2025-07-14 09:21:35 +05:30
harshsbhat
36e0e62f01 plain integration 2025-07-14 09:02:25 +05:30
harshsbhat
6d2bd9210c Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-28 11:29:48 +05:30
harshsbhat
636374ae04 add mapping 2025-06-16 23:43:21 +05:30
harshsbhat
b0627fffa5 tweaks 2025-06-16 13:44:00 +05:30
harshsbhat
84a94ad027 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-16 10:47:28 +05:30
harshsbhat
55a1b95988 temp 2025-05-22 16:12:15 +05:30
harshsbhat
bdf1698c05 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-05-22 14:50:49 +05:30
harshsbhat
c761f51b0e first commit 2025-05-20 18:44:50 +05:30
36 changed files with 3146 additions and 120 deletions

View File

@@ -0,0 +1,116 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
interface Column<T> {
/** Header text rendered in the table head */
header: React.ReactNode;
/** Cell renderer for an item */
render: (item: T) => React.ReactNode;
}
interface ActionButtonProps {
label: string;
onClick: () => void;
/** Optional Lucide Icon */
icon?: React.ReactNode;
/** Tooltip content */
tooltip?: string;
/** Variant override */
variant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
}
interface IntegrationListPanelProps<T> {
readonly environment: TEnvironment;
readonly statusNode: React.ReactNode;
readonly reconnectAction: ActionButtonProps;
readonly addNewAction: ActionButtonProps;
readonly emptyMessage: string;
readonly items: T[];
readonly columns: Column<T>[];
readonly onRowClick: (index: number) => void;
readonly getRowKey?: (item: T, index: number) => string | number;
}
export function IntegrationListPanel<T>({
environment,
statusNode,
reconnectAction,
addNewAction,
emptyMessage,
items,
columns,
onRowClick,
getRowKey,
}: IntegrationListPanelProps<T>) {
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{/* Toolbar */}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">{statusNode}</div>
{/* Re-connect */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={reconnectAction.variant ?? "outline"} onClick={reconnectAction.onClick}>
{reconnectAction.icon}
{reconnectAction.label}
</Button>
</TooltipTrigger>
{reconnectAction.tooltip && <TooltipContent>{reconnectAction.tooltip}</TooltipContent>}
</Tooltip>
</TooltipProvider>
{/* Add new */}
<Button variant={addNewAction.variant ?? "default"} onClick={addNewAction.onClick}>
{addNewAction.icon}
{addNewAction.label}
</Button>
</div>
{/* Empty table view */}
{!items || items.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={emptyMessage}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{columns.map((col) => (
<div key={`hdr-${String(col.header)}`} className="col-span-2 hidden text-center sm:block">
{col.header}
</div>
))}
</div>
{items.map((item, index) => {
const key = getRowKey ? getRowKey(item, index) : index;
return (
<button
key={key}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => onRowClick(index)}>
{columns.map((col) => (
<div key={`cell-${String(col.header)}`} className="col-span-2 text-center">
{col.render(item)}
</div>
))}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { TFnType } from "@tolgee/react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export interface QuestionItem {
id: string;
name: string;
type: TSurveyQuestionTypeEnum;
}
/**
* Build a flat list of selectable question / metadata items for integrations.
* Extracted to avoid duplication between integration modals.
*/
export const buildQuestionItems = (
selectedSurvey: TSurvey | null | undefined,
t: TFnType
): QuestionItem[] => {
const questions: QuestionItem[] = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
})) || []
: [];
const variables: QuestionItem[] =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields: QuestionItem[] = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const metadata: QuestionItem[] = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt: QuestionItem[] = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...metadata, ...createdAt];
};

View File

@@ -1,15 +1,14 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -35,7 +34,7 @@ import {
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddIntegrationModalProps {
environmentId: string;
@@ -118,47 +117,7 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];
const variables =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const Metadata = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
useEffect(() => {
if (selectedIntegration) {

View File

@@ -5,8 +5,6 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
@@ -14,6 +12,7 @@ import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
interface ManageIntegrationProps {
environment: TEnvironment;
@@ -70,78 +69,58 @@ export const ManageIntegration = ({
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</>
}
reconnectAction={{
label: t("environments.integrations.notion.update_connection"),
onClick: handleNotionAuthorization,
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.notion.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.notion.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.notion.link_new_database")}
},
}}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationNotionConfigData) => item.surveyName,
},
{
header: t("environments.integrations.notion.database_name"),
render: (item: TIntegrationNotionConfigData) => item.databaseName,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationNotionConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationNotionConfigData, idx) => `${idx}-${item.databaseId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.notion.database_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
</div>
</div>
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
@@ -151,6 +130,6 @@ export const ManageIntegration = ({
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>
</>
);
};

View File

@@ -6,6 +6,7 @@ import JsLogo from "@/images/jslogo.png";
import MakeLogo from "@/images/make-small.png";
import n8nLogo from "@/images/n8n.png";
import notionLogo from "@/images/notion.png";
import PlainCom from "@/images/plain.webp";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
@@ -50,6 +51,7 @@ const Page = async (props) => {
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
const isNotionIntegrationConnected = isIntegrationConnected("notion");
const isPlainIntegrationConnected = isIntegrationConnected("plain");
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
@@ -207,6 +209,20 @@ const Page = async (props) => {
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
disabled: isReadOnly,
},
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/activepieces",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/integrations/plain`,
connectText: `${isPlainIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
label: "Plain",
description: t("environments.integrations.plain.plain_integration_description"),
icon: <Image src={PlainCom} alt="Plain.com Logo" />,
connected: isPlainIntegrationConnected,
statusText: isPlainIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
];
integrationCards.unshift({

View File

@@ -0,0 +1,62 @@
"use server";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import type { TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
const ZConnectPlainIntegration = z.object({
environmentId: ZId,
key: z.string().min(1),
});
export const connectPlainIntegrationAction = authenticatedActionClient
.schema(ZConnectPlainIntegration)
.action(async ({ ctx, parsedInput }) => {
const { environmentId, key } = parsedInput;
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const encryptedAccessToken = symmetricEncrypt(key, ENCRYPTION_KEY);
const existingIntegration = await getIntegrationByType(environmentId, "plain");
const plainData: TIntegrationPlainConfigData[] =
existingIntegration?.type === "plain"
? (existingIntegration.config.data as TIntegrationPlainConfigData[])
: [];
const integration = await createOrUpdateIntegration(environmentId, {
type: "plain",
config: {
key: encryptedAccessToken,
data: plainData,
},
});
return {
success: true,
integration,
};
});

View File

@@ -0,0 +1,567 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
return callback();
},
register: vi.fn(),
setValue: vi.fn(),
watch: vi.fn(),
formState: { errors: {} },
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string) => {
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "common.first_name") return "First Name";
if (key === "common.last_name") return "Last Name";
if (key === "common.email") return "Email";
if (key === "common.select_survey") return "Select survey";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.plain.configure_plain_integration")
return "Configure Plain Integration";
if (key === "environments.integrations.plain.plain_integration_description")
return "Connect your Plain account to send survey responses as threads.";
if (key === "environments.integrations.plain.plain_logo") return "Plain logo";
if (key === "environments.integrations.plain.map_formbricks_fields_to_plain")
return "Map Formbricks fields to Plain";
if (key === "environments.integrations.plain.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.plain.select_a_field_to_map") return "Select a field to map";
if (key === "environments.integrations.plain.enter_label_id") return "Enter Label ID";
if (key === "environments.integrations.plain.connect") return "Connect";
if (key === "environments.integrations.plain.no_contact_info_question")
return "No contact info question found in survey";
if (key === "environments.integrations.plain.contact_info_missing_fields")
return "Contact info question is missing required fields:";
if (key === "environments.integrations.plain.contact_info_warning") return "Contact Info Warning";
if (key === "environments.integrations.plain.contact_info_missing_fields_description")
return "The following fields are missing";
if (key === "environments.integrations.plain.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.plain.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.plain.please_complete_mapping_fields")
return "Please complete mapping fields.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
// Create a mock survey with a ContactInfo question
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: true },
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Partial Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: false }, // Missing lastName
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration1",
type: "plain",
environmentId: environmentId,
config: {
key: "test-api-key",
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationPlainConfigData & { index: number } = {
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
includeCreatedAt: true,
includeComponents: true,
labelId: "custom-label",
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Plain)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockPlainIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("Configure Plain Integration")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeInTheDocument();
});
test("shows survey selection and enables mapping when survey is selected", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
// Select a survey
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Check if mapping section appears
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if default mapping rows are present
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Two default mapping rows
});
test("adds and removes mapping rows", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select a survey first
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Initial mapping rows
let plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Two default rows
// Add a new row
await user.click(plusButtons[0]);
// Check if a new row was added
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(3); // Now three rows
// Try to remove a row (not the mandatory ones)
const trashButtons = screen.getAllByTestId("trash-icon");
expect(trashButtons).toHaveLength(1); // Only the new row should be removable
await user.click(trashButtons[0]);
// Check if row was removed
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Back to two rows
});
test("shows warning for survey with incomplete contact info", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select survey with incomplete contact info
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[1].id);
// Check if warning appears
expect(screen.getByText("Contact Info Warning")).toBeInTheDocument();
expect(screen.getByText(/Last Name/)).toBeInTheDocument(); // Missing field
});
test("handles form submission with validation errors", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Try to submit without selecting a survey
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
// Check if error toast was shown
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
test("handles successful integration update", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Change a mapping
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
await user.selectOptions(questionDropdowns[0], "q2"); // Change to Contact Info question
// Submit the form
const updateButton = screen.getByRole("button", { name: "Update" });
await user.click(updateButton);
// Check if integration was updated
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration updated successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles integration deletion", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Click delete button
const deleteButton = screen.getByRole("button", { name: "Delete" });
await user.click(deleteButton);
// Check if integration was deleted
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Click cancel button
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(cancelButton);
// Check if modal was closed
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,627 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import PlainLogo from "@/images/plain.webp";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
TPlainMapping,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { INITIAL_MAPPING, PLAIN_FIELD_TYPES } from "../constants";
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
plainIntegration: TIntegrationPlain;
selectedIntegration: (TIntegrationPlainConfigData & { index: number }) | null;
}
export const AddIntegrationModal = ({
environmentId,
surveys,
open,
setOpen,
plainIntegration,
selectedIntegration,
}: AddIntegrationModalProps) => {
const { t } = useTranslate();
const { handleSubmit } = useForm();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [mapping, setMapping] = useState<
{
plainField: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
question: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
} | null;
isMandatory?: boolean;
}[]
>(INITIAL_MAPPING.map((m) => ({ ...m })));
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingIntegration, setIsLinkingIntegration] = useState(false);
const plainFieldTypes = PLAIN_FIELD_TYPES;
// State to track custom label ID values
const [labelIdValues, setLabelIdValues] = useState<Record<string, string>>({});
const plainIntegrationData: TIntegrationInput = {
type: "plain",
config: {
key: plainIntegration?.config?.key,
data: plainIntegration.config?.data || [],
},
};
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
const checkContactInfoQuestion = (survey: TSurvey | null) => {
if (!survey) return { hasContactInfo: false, missingFields: [] };
// Find ContactInfo questions in the survey
const contactInfoQuestions = survey.questions.filter(
(q) => q.type === TSurveyQuestionTypeEnum.ContactInfo
);
if (contactInfoQuestions.length === 0) {
return { hasContactInfo: false, missingFields: [] };
}
// Check if any ContactInfo question has all required fields enabled
for (const question of contactInfoQuestions) {
const contactQuestion = question as any; // Type assertion to access fields
const missingFields: string[] = [];
if (!contactQuestion.firstName?.show) {
missingFields.push("firstName");
}
if (!contactQuestion.lastName?.show) {
missingFields.push("lastName");
}
if (!contactQuestion.email?.show) {
missingFields.push("email");
}
// If this question has all required fields, return success
if (missingFields.length === 0) {
return {
hasContactInfo: true,
missingFields: [],
questionId: question.id,
question: contactQuestion,
};
}
// Otherwise continue checking other questions
}
// If we get here, we found ContactInfo questions but none with all required fields
return {
hasContactInfo: true,
missingFields: ["firstName", "lastName", "email"],
partialMatch: true,
};
};
useEffect(() => {
if (selectedIntegration) {
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
// Ensure mandatory fields remain protected from deletion when editing
setMapping(
selectedIntegration.mapping.map((m) => ({
...m,
// Re-apply mandatory flag based on field id
isMandatory: m.plainField.id === "threadTitle" || m.plainField.id === "componentText",
}))
);
// Initialize labelIdValues from existing mapping
const newLabelIdValues: Record<string, string> = {};
selectedIntegration.mapping.forEach((m, idx) => {
if (m.plainField.id === "labelTypeId") {
newLabelIdValues[idx] = m.question.id;
}
});
setLabelIdValues(newLabelIdValues);
return;
}
resetForm();
}, [selectedIntegration, surveys]);
// State to track contact info validation results
const [contactInfoValidation, setContactInfoValidation] = useState<{
hasContactInfo: boolean;
missingFields: string[];
partialMatch?: boolean;
questionId?: string;
question?: any;
}>({ hasContactInfo: false, missingFields: [] });
// Check for ContactInfo question when survey is selected
useEffect(() => {
if (selectedSurvey) {
const contactCheck = checkContactInfoQuestion(selectedSurvey);
setContactInfoValidation(contactCheck);
} else {
setContactInfoValidation({ hasContactInfo: false, missingFields: [] });
}
}, [selectedSurvey]);
const linkIntegration = async () => {
try {
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
const contactCheck = checkContactInfoQuestion(selectedSurvey);
if (!contactCheck.hasContactInfo) {
toast.error(t("environments.integrations.plain.no_contact_info_question"));
return;
} else if (contactCheck.partialMatch || contactCheck.missingFields.length > 0) {
const missingFieldsFormatted = contactCheck.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ");
toast.error(
`${t("environments.integrations.plain.contact_info_missing_fields")} ${missingFieldsFormatted}.`
);
return;
}
if (mapping.length === 0 || (mapping.length === 1 && !mapping[0].question.id)) {
throw new Error(t("environments.integrations.plain.please_select_at_least_one_mapping"));
}
if (mapping.filter((m) => m.error).length > 0) {
throw new Error(t("environments.integrations.plain.please_resolve_mapping_errors"));
}
if (mapping.filter((m) => !m.question.id).length >= 1) {
throw new Error(t("environments.integrations.plain.please_complete_mapping_fields"));
}
setIsLinkingIntegration(true);
// Find Label ID mapping if it exists
const labelIdMapping = mapping.find((m) => m.plainField.id === "labelTypeId");
const labelId = labelIdMapping?.question.id || "";
const integrationData: TIntegrationPlainConfigData = {
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
mapping: mapping.map((m) => {
const { error, ...rest } = m;
return rest as TPlainMapping;
}),
includeCreatedAt: true,
includeComponents: true,
labelId: labelId, // Add the Label ID from the mapping
createdAt: new Date(),
};
if (selectedIntegration) {
// update action
plainIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
plainIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingIntegration(false);
}
};
const deleteLink = async () => {
plainIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const resetForm = () => {
setIsLinkingIntegration(false);
setSelectedSurvey(null);
setLabelIdValues({});
setMapping(INITIAL_MAPPING.map((m) => ({ ...m })));
};
const getFilteredQuestionItems = (selectedIdx) => {
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
};
const createCopy = (item) => structuredClone(item);
const getFilteredPlainFieldTypes = (selectedIdx: number) => {
const selectedPlainFieldIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.plainField.id);
return plainFieldTypes.filter((field) => !selectedPlainFieldIds.includes(field.id));
};
const MappingRow = ({ idx }: { idx: number }) => {
const filteredQuestionItems = getFilteredQuestionItems(idx);
const filteredPlainFields = getFilteredPlainFieldTypes(idx);
const addRow = () => {
const usedFieldIds = mapping.map((m) => m.plainField.id);
const availableField = plainFieldTypes.find((field) => !usedFieldIds.includes(field.id)) || {
id: "threadField",
name: "Thread Field",
type: "threadField" as TPlainFieldType,
};
setMapping((prev) => [
...prev,
{
plainField: availableField,
question: { id: "", name: "", type: "" },
isMandatory: false,
},
]);
};
const deleteRow = () => {
if (mapping[idx].isMandatory) return;
setMapping((prev) => {
return prev.filter((_, i) => i !== idx);
});
};
interface ErrorMsgProps {
error:
| {
type: string;
msg: React.ReactNode | string;
}
| null
| undefined;
field?: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
ques?: { id: string; name: string; type: string };
}
const ErrorMsg = ({ error }: ErrorMsgProps) => {
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{error.msg}
</div>
);
};
return (
<div className="w-full">
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
field={mapping[idx].plainField}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
{mapping[idx].plainField.id === "labelTypeId" ? (
<div className="max-w-full flex-1">
<input
type="text"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200"
placeholder={t("environments.integrations.plain.enter_label_id")}
value={labelIdValues[idx] || ""}
onChange={(e) => {
setLabelIdValues((prev) => ({
...prev,
[idx]: e.target.value,
}));
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: {
id: e.target.value,
name: "Label ID",
type: "labelTypeId",
},
error: null,
};
return copy;
});
}}
/>
</div>
) : (
// Regular question dropdown for non-Label ID fields
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_survey_question")}
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: item,
error: null,
};
return copy;
});
}}
disabled={questionItems.length === 0}
/>
</div>
)}
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_field_to_map")}
items={filteredPlainFields}
selectedItem={mapping?.[idx]?.plainField}
disabled={filteredPlainFields.length === 0}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
plainField: item,
error: null,
};
return copy;
});
}}
/>
</div>
</div>
<div className="flex space-x-2">
{!mapping[idx].isMandatory && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={PlainLogo}
alt={t("environments.integrations.plain.plain_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.plain.configure_plain_integration")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.plain.plain_integration_description")}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit(linkIntegration)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label={t("common.select_survey")}
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
</p>
{/* Contact Info Validation Alerts */}
{selectedSurvey && (
<>
{/* Success all required fields present */}
{contactInfoValidation.hasContactInfo &&
contactInfoValidation.missingFields.length === 0 && (
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_success_title", {
defaultValue: "Contact-Info question found",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_all_present", {
defaultValue:
"This survey contains a complete Contact-Info question (first name, last name & email).",
})}
</p>
</div>
)}
{/* Error no contact info question */}
{!contactInfoValidation.hasContactInfo && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_missing_title", {
defaultValue: "No Contact-Info question",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.no_contact_info_question", {
defaultValue:
"This survey does not include a Contact-Info question. Please add one with first name, last name and email enabled to use Plain.",
})}
</p>
<a
href="https://formbricks.com/docs/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
{/* Warning partial match (retain existing implementation) */}
{contactInfoValidation.hasContactInfo && contactInfoValidation.partialMatch && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_warning")}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_missing_fields_description")}:{" "}
{contactInfoValidation.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ")}
</p>
<a
href="https://docs.formbricks.com/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
</>
)}
</div>
{selectedSurvey && (
<div className="space-y-4">
<div>
<Label>{t("environments.integrations.plain.map_formbricks_fields_to_plain")}</Label>
<p className="mt-1 text-xs text-slate-500">
{t("environments.integrations.plain.mandatory_mapping_note", {
defaultValue:
"Thread Title and Component Text are mandatory mappings and cannot be removed.",
})}
</p>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
</div>
</div>
</div>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingIntegration}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.plain.connect")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,71 @@
import { AddKeyModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddKeyModal";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { connectPlainIntegrationAction } from "../actions";
vi.mock("../actions", () => ({
connectPlainIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast");
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("AddKeyModal", () => {
const environmentId = "test-environment-id";
const setOpen = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("should disable the connect button when the API key is empty", () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).toBeDisabled();
});
test("should enable the connect button when the API key is not empty", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).not.toBeDisabled();
});
test("should call the connect action and show a success toast on successful connection", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(connectPlainIntegrationAction).toHaveBeenCalledWith({
environmentId,
key: "test-api-key",
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.plain.connection_success");
expect(setOpen).toHaveBeenCalledWith(false);
});
});
test("should show an error toast on a failed connection", async () => {
(connectPlainIntegrationAction as Mock).mockRejectedValue(new Error("Connection error"));
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("environments.integrations.plain.connection_error");
});
});
});

View File

@@ -0,0 +1,91 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { KeyIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { connectPlainIntegrationAction } from "../actions";
interface AddKeyModalProps {
environmentId: string;
open?: boolean;
setOpen?: (open: boolean) => void;
}
export const AddKeyModal = ({
environmentId,
open: externalOpen,
setOpen: externalSetOpen,
}: AddKeyModalProps) => {
const { t } = useTranslate();
const [internalOpen, setInternalOpen] = useState(false);
const [keyLabel, setKeyLabel] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalSetOpen || setInternalOpen;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<KeyIcon className="h-5 w-5" />
</div>
<div>
<span className="font-medium">{t("environments.integrations.plain.add_key")}</span>
<p className="text-sm font-normal text-slate-500">
{t("environments.integrations.plain.add_key_description")}
</p>
</div>
</DialogTitle>
</DialogHeader>
<div>
<div className="mb-4">
<label htmlFor="keyLabel" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.integrations.plain.api_key_label")}
</label>
<Input
id="keyLabel"
name="keyLabel"
placeholder="plainApiKey_123"
value={keyLabel}
onChange={(e) => setKeyLabel(e.target.value)}
className="w-full"
/>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button
variant="default"
disabled={!keyLabel.trim() || isSubmitting}
onClick={async () => {
try {
setIsSubmitting(true);
await connectPlainIntegrationAction({
environmentId,
key: keyLabel.trim(),
});
toast.success(t("environments.integrations.plain.connection_success"));
setOpen(false);
} catch {
toast.error(t("environments.integrations.plain.connection_error"));
} finally {
setIsSubmitting(false);
}
}}>
{isSubmitting ? <LoadingSpinner className="h-4 w-4" /> : t("common.connect")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,186 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/time", () => ({
timeSince: vi.fn((time) => `mock-time-since-${time}`),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((err) => err?.message || "An error occurred"),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }) =>
open ? (
<div>
<span>{text}</span>
<button onClick={() => onDelete()}>{isDeleting ? "Deleting..." : "Delete"}</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }) => <div>{emptyMessage}</div>,
}));
vi.mock("@/lib/constants", () => {
const base = {
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
ENTERPRISE_LICENSE_KEY: undefined,
POSTHOG_API_KEY: undefined,
POSTHOG_HOST: undefined,
IS_POSTHOG_CONFIGURED: false,
GITHUB_ID: undefined,
GITHUB_SECRET: undefined,
GOOGLE_CLIENT_ID: undefined,
GOOGLE_CLIENT_SECRET: undefined,
AZUREAD_CLIENT_ID: undefined,
AZUREAD_CLIENT_SECRET: undefined,
AZUREAD_TENANT_ID: undefined,
OIDC_DISPLAY_NAME: undefined,
OIDC_CLIENT_ID: undefined,
OIDC_ISSUER: undefined,
OIDC_CLIENT_SECRET: undefined,
OIDC_SIGNING_ALGORITHM: undefined,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
WEBAPP_URL: undefined,
SENTRY_DSN: undefined,
SENTRY_RELEASE: undefined,
SENTRY_ENVIRONMENT: undefined,
};
return new Proxy(base, {
get(target, prop) {
return prop in target ? target[prop as keyof typeof target] : undefined;
},
});
});
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
const mockIntegrationData: TIntegrationPlainConfigData[] = [
{
surveyId: "survey-1",
surveyName: "Survey One",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
{
surveyId: "survey-2",
surveyName: "Survey Two",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: mockIntegrationData,
},
};
describe("ManageIntegration", () => {
let setOpenAddIntegrationModal: (isOpen: boolean) => void;
let setIsConnected: (isConnected: boolean) => void;
let setSelectedIntegration: (integration: (TIntegrationPlainConfigData & { index: number }) | null) => void;
beforeEach(() => {
setOpenAddIntegrationModal = vi.fn();
setIsConnected = vi.fn();
setSelectedIntegration = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("renders empty state when no integrations are configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={{ ...mockPlainIntegration, config: { ...mockPlainIntegration.config, data: [] } }}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getByText("environments.integrations.plain.no_databases_found")).toBeInTheDocument();
});
test("renders a list of integrations when configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getAllByText("Survey One")[0]).toBeInTheDocument();
expect(screen.getAllByText("Survey Two")[0]).toBeInTheDocument();
});
test("handles successful deletion of an integration", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: mockPlainIntegration });
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
await userEvent.click(screen.getAllByText("environments.integrations.delete_integration")[0]);
expect(screen.getByText("environments.integrations.delete_integration_confirmation")).toBeInTheDocument();
await userEvent.click(screen.getByText("Delete"));
await waitFor(() => {
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: mockPlainIntegration.id });
});
});
});

View File

@@ -0,0 +1,133 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
import { AddKeyModal } from "./AddKeyModal";
interface ManageIntegrationProps {
environment: TEnvironment;
plainIntegration: TIntegrationPlain;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedIntegration: React.Dispatch<
React.SetStateAction<(TIntegrationPlainConfigData & { index: number }) | null>
>;
locale: TUserLocale;
}
export const ManageIntegration = ({
environment,
plainIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationPlainConfigData[] = [];
if (plainIntegration?.config.data) {
integrationArray = plainIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
const deleteIntegrationActionResult = await deleteIntegrationAction({
integrationId: plainIntegration.id,
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
toast.error(errorMessage);
}
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
};
const editIntegration = (index: number) => {
setSelectedIntegration({ ...plainIntegration.config.data[index], index });
setOpenAddIntegrationModal(true);
};
return (
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">{t("common.connected")}</span>
</>
}
reconnectAction={{
label: t("environments.integrations.plain.update_connection"),
onClick: () => setIsKeyModalOpen(true),
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.plain.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.plain.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
},
}}
emptyMessage={t("environments.integrations.plain.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationPlainConfigData) => item.surveyName,
},
{
header: t("common.survey_id"),
render: (item: TIntegrationPlainConfigData) => item.surveyId,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationPlainConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationPlainConfigData, idx) => `${idx}-${item.surveyId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
<AddKeyModal environmentId={environment.id} open={isKeyModalOpen} setOpen={setIsKeyModalOpen} />
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.plain.plain_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</>
);
};

View File

@@ -0,0 +1,81 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { PlainWrapper } from "./PlainWrapper";
// Mock child components
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(() => <div>Mocked ConnectIntegration</div>),
}));
vi.mock("./AddIntegrationModal", () => ({
AddIntegrationModal: vi.fn(() => <div>Mocked AddIntegrationModal</div>),
}));
vi.mock("./AddKeyModal", () => ({
AddKeyModal: vi.fn(() => <div>Mocked AddKeyModal</div>),
}));
vi.mock("./ManageIntegration", () => ({
ManageIntegration: vi.fn(() => <div>Mocked ManageIntegration</div>),
}));
const mockEnvironment = {
id: "test-env-id",
name: "Test Environment",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: [],
},
};
describe("PlainWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<PlainWrapper
plainIntegration={undefined}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ConnectIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ManageIntegration")).not.toBeInTheDocument();
});
test("renders ManageIntegration when connected", () => {
render(
<PlainWrapper
plainIntegration={mockPlainIntegration}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ManageIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ConnectIntegration")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
"use client";
import PlainLogo from "@/images/plain.webp";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "./AddIntegrationModal";
import { AddKeyModal } from "./AddKeyModal";
import { ManageIntegration } from "./ManageIntegration";
interface PlainWrapperProps {
plainIntegration: TIntegrationPlain | undefined;
enabled: boolean;
environment: TEnvironment;
webAppUrl: string;
surveys: TSurvey[];
databasesArray: any[];
locale: TUserLocale;
}
export const PlainWrapper = ({
plainIntegration,
enabled,
environment,
surveys,
locale,
}: PlainWrapperProps) => {
const [isModalOpen, setModalOpen] = useState(false);
const [open, setOpen] = useState(false);
const [isConnected, setIsConnected] = useState(plainIntegration ? plainIntegration.config.key : false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationPlainConfigData & { index: number }) | null
>(null);
const handlePlainAuthorization = async () => {
setOpen(true);
};
return (
<>
{isConnected && plainIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
plainIntegration={plainIntegration}
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
plainIntegration={plainIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={locale}
/>
</>
) : (
<>
<AddKeyModal environmentId={environment.id} open={open} setOpen={setOpen} />
<ConnectIntegration
isEnabled={enabled}
integrationType={"plain"}
handleAuthorization={handlePlainAuthorization}
integrationLogoSrc={PlainLogo}
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,24 @@
import { TPlainFieldType } from "@formbricks/types/integration/plain";
export const PLAIN_FIELD_TYPES: {
id: string;
name: string;
type: TPlainFieldType;
}[] = [
{ id: "threadTitle", name: "Thread Title", type: "threadField" as TPlainFieldType },
{ id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
{ id: "labelTypeId", name: "Label ID", type: "labelTypeId" as TPlainFieldType },
];
export const INITIAL_MAPPING = [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
] as const;

View File

@@ -0,0 +1,210 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper", () => ({
PlainWrapper: vi.fn(
({ enabled, surveys, environment, plainIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked PlainWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{plainIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databasesArray">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
type: "development",
projectId: "project-id",
project: {
id: "project-id",
name: "Test Project",
environments: [],
people: [],
surveys: [],
tags: [],
webhooks: [],
apiKey: {
id: "api-key",
createdAt: new Date(),
updatedAt: new Date(),
hashedKey: "hashed",
label: "api",
},
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-id",
recontactDays: 30,
inAppSurveyBranding: false,
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
},
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockPlainIntegration = {
id: "integration1",
type: "plain",
environmentId: "test-env-id",
config: {
key: "plain-key",
data: [],
},
} as unknown as TIntegrationPlain;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("PlainIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: true,
hasManageAccess: false,
isReadOnly: false,
});
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockPlainIntegration);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with PlainWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.plain.plain_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked PlainWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockPlainIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("https://app.formbricks.com");
expect(screen.getByTestId("databasesArray")).toHaveTextContent("0");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`https://app.formbricks.com/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: false,
hasManageAccess: false,
isReadOnly: true,
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
});

View File

@@ -0,0 +1,49 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { PlainWrapper } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, plainIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "plain"),
]);
const databasesArray = [];
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.plain.plain_integration") || "Plain Integration"} />
<PlainWrapper
enabled={true}
surveys={surveys}
environment={environment}
plainIntegration={plainIntegration as TIntegrationPlain}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
/>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -4,6 +4,7 @@ import { NOTION_RICH_TEXT_LIMIT } from "@/lib/constants";
import { writeData } from "@/lib/googleSheet/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { writeData as writeDataToPlain } from "@/lib/plain/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -15,6 +16,7 @@ import { TIntegration, TIntegrationType } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -109,6 +111,13 @@ export const handleIntegrations = async (
logger.error(notionResult.error, "Error in notion integration");
}
break;
case "plain": {
const plainResult = await handlePlainIntegration(integration as TIntegrationPlain, data);
if (!plainResult.ok) {
logger.error(plainResult.error, "Error in plain integration");
}
break;
}
}
}
};
@@ -436,3 +445,29 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
throw new Error("Payload build failed!");
}
};
const handlePlainIntegration = async (
integration: TIntegrationPlain,
data: TPipelineInput
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const configData: TIntegrationPlainConfigData = element;
await writeDataToPlain(integration.config, data, configData);
}
}
}
return {
ok: true,
data: undefined,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};

View File

@@ -51,7 +51,7 @@ export const GET = async (req: NextRequest) => {
});
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
tokenData.access_token = encryptedAccessToken;
const notionIntegration: TIntegrationNotionInput = {

BIN
apps/web/images/plain.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -61,7 +61,7 @@ export const writeData = async (
};
const getHeaders = (config: TIntegrationNotionConfig) => {
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY!);
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY);
return {
Accept: "application/json",
"Content-Type": "application/json",

View File

@@ -0,0 +1,283 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { symmetricDecrypt } from "@/lib/crypto";
import { PlainClient } from "@team-plain/typescript-sdk";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { TIntegrationPlainConfig, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { writeData } from "./service";
// Mock dependencies before importing the module under test
vi.mock("@team-plain/typescript-sdk", () => {
return {
PlainClient: vi.fn(),
};
});
vi.mock("@/lib/crypto", () => {
return {
symmetricDecrypt: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => {
return {
logger: {
error: vi.fn(),
},
};
});
vi.mock("@/lib/constants", () => {
return {
ENCRYPTION_KEY: "test-encryption-key",
};
});
describe("Plain Service", () => {
// Mock data
const mockConfig: TIntegrationPlainConfig = {
key: "encrypted-api-key",
data: [],
};
const mockIntegrationConfig: TIntegrationPlainConfigData = {
surveyId: "survey-123",
surveyName: "Test Survey",
mapping: [
{
plainField: {
id: "threadTitle",
name: "Thread Title",
type: "title" as const,
},
question: {
id: "q1",
name: "Question 1",
type: "openText",
},
},
{
plainField: {
id: "componentText",
name: "Component Text",
type: "componentText" as const,
},
question: {
id: "q2",
name: "Question 2",
type: "openText",
},
},
{
plainField: {
id: "labelTypeId",
name: "Label Type",
type: "labelTypeId" as const,
},
question: {
id: "q3",
name: "Question 3",
type: "openText",
},
},
],
includeCreatedAt: true,
includeComponents: true,
createdAt: new Date(),
};
const mockPipelineInput: TPipelineInput = {
environmentId: "env-123",
surveyId: "survey-123",
event: "responseFinished",
response: {
id: "response-123",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey-123",
finished: true,
data: {
q1: "Test Thread Title",
q2: "This is the component text content",
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
meta: { url: "https://example.com" },
contact: null,
contactAttributes: null,
variables: {},
notes: [],
tags: [],
singleUseId: null,
language: null,
},
};
// Mock implementations
const mockUpsertCustomer = vi.fn().mockResolvedValue({});
const mockCreateThread = vi.fn().mockResolvedValue({
id: "thread-123",
title: "Test Thread",
});
const mockPlainClientInstance = {
upsertCustomer: mockUpsertCustomer,
createThread: mockCreateThread,
};
// Setup before each test
beforeEach(() => {
vi.clearAllMocks();
// Setup PlainClient mock
vi.mocked(PlainClient).mockImplementation(() => mockPlainClientInstance as unknown as PlainClient);
// Setup symmetricDecrypt mock
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-api-key");
});
test("successfully sends data to Plain", async () => {
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-api-key", "test-encryption-key");
expect(PlainClient).toHaveBeenCalledWith({ apiKey: "decrypted-api-key" });
// Verify customer creation
expect(mockUpsertCustomer).toHaveBeenCalledWith({
identifier: {
emailAddress: "john.doe@example.com",
},
onCreate: {
fullName: "John Doe",
email: {
email: "john.doe@example.com",
isVerified: false,
},
},
onUpdate: {
fullName: {
value: "John Doe",
},
},
});
// Verify thread creation
expect(mockCreateThread).toHaveBeenCalledWith({
title: "Test Thread Title",
customerIdentifier: {
emailAddress: "john.doe@example.com",
},
components: [
{
componentText: {
text: "This is the component text content",
},
},
],
labelTypeIds: ["label-456"],
});
expect(result).toEqual(ok(undefined));
});
test("returns error when title is missing", async () => {
// Arrange
const inputWithoutTitle: TPipelineInput = {
...mockPipelineInput,
response: {
...mockPipelineInput.response,
data: {
// No q1 (title) field
q2: "This is the component text content",
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
},
};
// Act
const result = await writeData(mockConfig, inputWithoutTitle, mockIntegrationConfig);
// Assert
expect(result).toEqual(err(new Error("Missing title in response data.")));
expect(mockUpsertCustomer).not.toHaveBeenCalled();
expect(mockCreateThread).not.toHaveBeenCalled();
});
test("returns error when component text is missing", async () => {
// Arrange
const inputWithoutComponentText: TPipelineInput = {
...mockPipelineInput,
response: {
...mockPipelineInput.response,
data: {
q1: "Test Thread Title",
// No q2 (component text) field
q3: "label-456",
contactInfo: ["John", "Doe", "john.doe@example.com"],
},
},
};
// Act
const result = await writeData(mockConfig, inputWithoutComponentText, mockIntegrationConfig);
// Assert
expect(result).toEqual(err(new Error("Missing component text in response data.")));
expect(mockUpsertCustomer).not.toHaveBeenCalled();
expect(mockCreateThread).not.toHaveBeenCalled();
});
test("creates thread without label when labelId is not mapped", async () => {
// Arrange
const configWithoutLabel: TIntegrationPlainConfigData = {
...mockIntegrationConfig,
mapping: mockIntegrationConfig.mapping.filter((m) => m.plainField.id !== "labelTypeId"),
};
// Act
const result = await writeData(mockConfig, mockPipelineInput, configWithoutLabel);
// Assert
expect(mockCreateThread).toHaveBeenCalledWith(
expect.not.objectContaining({
labelTypeIds: expect.anything(),
})
);
expect(result).toEqual(ok(undefined));
});
test("handles API errors gracefully", async () => {
// Arrange
const apiError = new Error("API Error");
mockUpsertCustomer.mockRejectedValueOnce(apiError);
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(logger.error).toHaveBeenCalledWith("Exception in Plain writeData function", {
error: apiError,
});
expect(result).toEqual(err(apiError));
});
test("handles decryption errors", async () => {
// Arrange
const decryptionError = new Error("Decryption failed");
vi.mocked(symmetricDecrypt).mockImplementationOnce(() => {
throw decryptionError;
});
// Act
const result = await writeData(mockConfig, mockPipelineInput, mockIntegrationConfig);
// Assert
expect(logger.error).toHaveBeenCalledWith("Exception in Plain writeData function", {
error: decryptionError,
});
expect(result).toEqual(err(decryptionError));
});
});

View File

@@ -0,0 +1,108 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { PlainClient } from "@team-plain/typescript-sdk";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TIntegrationPlainConfig, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
/**
* Function that handles sending survey response data to Plain
*/
export const writeData = async (
config: TIntegrationPlainConfig,
data: TPipelineInput,
integrationConfig: TIntegrationPlainConfigData
): Promise<Result<void, Error>> => {
try {
const decryptedToken = symmetricDecrypt(config.key, ENCRYPTION_KEY);
const client = new PlainClient({
apiKey: decryptedToken,
});
const titleId = integrationConfig.mapping.find((m) => m.plainField.id === "threadTitle")?.question.id;
const componentTextId = integrationConfig.mapping.find((m) => m.plainField.id === "componentText")
?.question.id;
const labelId = integrationConfig.mapping.find((m) => m.plainField.id === "labelTypeId")?.question.id;
const rawTitle = titleId ? data.response.data[titleId] : undefined;
if (typeof rawTitle !== "string" || rawTitle.trim() === "") {
return err(new Error("Missing title in response data."));
}
const title = rawTitle.trim();
const rawComponentText = componentTextId ? data.response.data[componentTextId] : undefined;
if (typeof rawComponentText !== "string" || rawComponentText.trim() === "") {
return err(new Error("Missing component text in response data."));
}
const componentText = rawComponentText.trim();
const labelValue = labelId ? data.response.data[labelId] : undefined;
// Extract contact information from the response data
let firstName = "";
let lastName = "";
let email = "";
// Find contact info questions by detecting arrays with email pattern
Object.entries(data.response.data || {}).forEach(([_, answer]) => {
if (
Array.isArray(answer) &&
answer.length >= 3 &&
typeof answer[2] === "string" &&
answer[2].includes("@")
) {
firstName = String(answer[0] || "");
lastName = String(answer[1] || "");
email = String(answer[2] || "");
}
});
// Create a customer on Plain
await client.upsertCustomer({
identifier: {
emailAddress: email,
},
// If the customer is not found and should be created then
// these details will be used:
onCreate: {
fullName: `${firstName} ${lastName}`,
email: {
email: email,
isVerified: false, // or true, depending on your requirements
},
},
// If the customer already exists and should be updated then
// these details will be used. You can do partial updates by
// just providing some of the fields below.
onUpdate: {
fullName: {
value: `${firstName} ${lastName}`,
},
},
});
// Create a thread on Plain
await client.createThread({
title: title,
customerIdentifier: {
emailAddress: email,
},
components: [
{
componentText: {
text: componentText,
},
},
],
...(typeof labelValue === "string" && labelValue.trim() !== ""
? { labelTypeIds: [labelValue.trim()] }
: {}),
});
return ok(undefined);
} catch (error) {
logger.error("Exception in Plain writeData function", { error });
return err(error instanceof Error ? error : new Error(String(error)));
}
};

View File

@@ -1,12 +1,12 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
export const fetchChannels = async (slackIntegration: TIntegrationSlack): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
// `nextCursor` is a pagination token returned by the Slack API. It indicates the presence of additional pages of data.
// When `nextCursor` is not empty, it should be included in subsequent requests to fetch the next page of data.

View File

@@ -134,6 +134,8 @@
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
"api_key_label": "Plain-API-Schlüssel",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API-Schlüssel",
"app": "App",
"app_survey": "App-Umfrage",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"plain": {
"add_key": "Plain-API-Schlüssel hinzufügen",
"add_key_description": "Fügen Sie Ihren Plain-API-Schlüssel hinzu, um sich mit Plain zu verbinden",
"api_key_label": "Plain-API-Schlüssel",
"configure_plain_integration": "Umfrage verknüpfen, um Plain-Threads zu erstellen",
"connect": "Link-Umfrage",
"connect_with_plain": "Mit Plain verbinden",
"connection_success": "Einfach verbunden",
"contact_info_all_present": "Um ein Ticket zu erstellen, muss Plain einen neuen Kunden mit Vorname, Nachname und E-Mail erstellen. Wir müssen diese Informationen mit dem Fragetyp \"Kontakt\" erfassen.",
"contact_info_missing_title": "Diese Umfrage fehlt erforderliche Fragen.",
"contact_info_success_title": "Diese Umfrage enthält alle erforderlichen Fragen.",
"enter_label_id": "Geben Sie die Label-ID aus Plain ein",
"link_new_database": "Neue Umfrage verknüpfen",
"mandatory_mapping_note": "Diskussionstitel und Komponententext sind Pflichtfelder für die Einrichtung der Plain-Integration",
"map_formbricks_fields_to_plain": "Formbricks-Antworten auf Plain-Felder abbilden",
"no_contact_info_question": "Um ein Ticket zu erstellen, muss Plain einen neuen Kunden mit Vorname, Nachname und E-Mail erstellen. Wir müssen diese Informationen mit dem Fragetyp \"Kontakt\" erfassen.",
"no_databases_found": "Keine Umfragen verbunden",
"plain_integration": "Plain Integration",
"plain_integration_description": "Threads auf Plain mit Formbricks-Antworten erstellen",
"select_a_survey_question": "Wähle eine Umfragefrage aus",
"update_connection": "Erneut mit Plain verbinden",
"update_connection_tooltip": "Plain-Verbindung aktualisieren"
},
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {

View File

@@ -134,6 +134,8 @@
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
"api_key_label": "Plain API key",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API Keys",
"app": "App",
"app_survey": "App Survey",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
},
"notion_integration_description": "Send data to your Notion database",
"plain": {
"add_key": "Add Plain API Key",
"add_key_description": "Add your Plain API Key to connect with Plain",
"api_key_label": "Plain API Key",
"configure_plain_integration": "Link survey to create Plain threads",
"connect": "Link survey",
"connect_with_plain": "Connect with Plain",
"connection_success": "Plain connected successfully",
"contact_info_all_present": "To be able to create a ticket, Plain requires to create a new customer with first name, last name and email. We need to capture this information using the question type Contact.",
"contact_info_missing_title": "This survey is missing necessary questions.",
"contact_info_success_title": "This survey has all necessary questions included.",
"enter_label_id": "Enter label ID from Plain",
"link_new_database": "Link new survey",
"mandatory_mapping_note": "Thread title and Component text are mandatory fields to setup the Plain integration",
"map_formbricks_fields_to_plain": "Map Formbricks responses to Plain fields",
"no_contact_info_question": "To be able to create a ticket, Plain requires to create a new customer with first name, last name and email. We need to capture this information using the question type Contact.",
"no_databases_found": "No surveys connected",
"plain_integration": "Plain Integration",
"plain_integration_description": "Create threads on Plain using Formbricks responses",
"select_a_survey_question": "Select a survey question",
"update_connection": "Reconnect Plain",
"update_connection_tooltip": "Update Plain connection"
},
"please_select_a_survey_error": "Please select a survey",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {

View File

@@ -134,6 +134,8 @@
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
"api_key_label": "Clé API Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Clés API",
"app": "Application",
"app_survey": "Sondage d'application",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
},
"notion_integration_description": "Envoyer des données à votre base de données Notion",
"plain": {
"add_key": "Ajouter une clé API Plain",
"add_key_description": "Ajoutez votre clé API Plain pour vous connecter avec Plain",
"api_key_label": "Clé API Plain",
"configure_plain_integration": "Lier l'enquête pour créer des fils Plain",
"connect": "Lier enquête",
"connect_with_plain": "Se connecter avec Plain",
"connection_success": "Connexion simple réussie",
"contact_info_all_present": "Pour pouvoir créer un ticket, Plain nécessite la création d'un nouveau client avec prénom, nom et email. Nous devons capturer ces informations à l'aide du type de question Contact.",
"contact_info_missing_title": "Ce sondage n'inclut pas les questions nécessaires.",
"contact_info_success_title": "Ce sondage comprend toutes les questions nécessaires.",
"enter_label_id": "Saisissez l'ID de l'étiquette de Plain",
"link_new_database": "Lier nouvelle enquête",
"mandatory_mapping_note": "Le titre du fil et le texte du composant sont des champs obligatoires pour configurer l'intégration simple.",
"map_formbricks_fields_to_plain": "Mapper les réponses de Formbricks aux champs Plain",
"no_contact_info_question": "Pour pouvoir créer un ticket, Plain nécessite la création d'un nouveau client avec prénom, nom et email. Nous devons capturer ces informations à l'aide du type de question Contact.",
"no_databases_found": "Aucune enquête connectée",
"plain_integration": "Intégration Plain",
"plain_integration_description": "Créer des fils sur Plain en utilisant les réponses de Formbricks",
"select_a_survey_question": "Sélectionnez une question d'enquête",
"update_connection": "Reconnecter Plain",
"update_connection_tooltip": "Mettre à jour la connexion Plain"
},
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {

View File

@@ -134,6 +134,8 @@
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
"api_key_label": "Chave API do Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Chaves de API",
"app": "app",
"app_survey": "Pesquisa de App",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"plain": {
"add_key": "Adicionar Chave API do Plain",
"add_key_description": "Adicione sua chave API do Plain para conectar com o Plain",
"api_key_label": "Chave API do Plain",
"configure_plain_integration": "Vincule a pesquisa para criar tópicos Plain",
"connect": "Pesquisa de Link",
"connect_with_plain": "Conectar com o Plain",
"connection_success": "Conexão realizada com sucesso",
"contact_info_all_present": "Para conseguir criar um ticket, a Plain requer a criação de um novo cliente com nome, sobrenome e email. Precisamos capturar essas informações usando o tipo de pergunta Contato.",
"contact_info_missing_title": "Esta pesquisa está sem as perguntas necessárias.",
"contact_info_success_title": "Esta pesquisa inclui todas as perguntas necessárias.",
"enter_label_id": "Insira o ID da etiqueta do Plain",
"link_new_database": "Link de nova pesquisa",
"mandatory_mapping_note": "O título do tópico e o texto do componente são campos obrigatórios para configurar a integração Plain",
"map_formbricks_fields_to_plain": "Mapear respostas do Formbricks para campos Plain",
"no_contact_info_question": "Para conseguir criar um ticket, a Plain requer a criação de um novo cliente com nome, sobrenome e email. Precisamos capturar essas informações usando o tipo de pergunta Contato.",
"no_databases_found": "Nenhuma pesquisa conectada",
"plain_integration": "Integração com o Plain",
"plain_integration_description": "Criar threads no Plain usando respostas do Formbricks",
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
"update_connection": "Reconectar Plain",
"update_connection_tooltip": "Atualizar conexão Plain"
},
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {

View File

@@ -134,6 +134,8 @@
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
"api_key_label": "Chave API do Plain",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "Chaves API",
"app": "Aplicação",
"app_survey": "Inquérito da Aplicação",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"plain": {
"add_key": "Adicionar Chave API do Plain",
"add_key_description": "Adicione a sua chave API do Plain para ligar ao Plain",
"api_key_label": "Chave API do Plain",
"configure_plain_integration": "Associar inquérito para criar tópicos Plain",
"connect": "Ligar inquérito",
"connect_with_plain": "Ligar ao Plain",
"connection_success": "Ligação simples realizada com sucesso",
"contact_info_all_present": "Para poder criar um ticket, a Plain exige criar um novo cliente com nome, sobrenome e email. Precisamos capturar essa informação usando o tipo de pergunta Contato.",
"contact_info_missing_title": "Este inquérito está a faltar perguntas necessárias.",
"contact_info_success_title": "Este inquérito inclui todas as perguntas necessárias.",
"enter_label_id": "Insira o ID do rótulo da Plain",
"link_new_database": "Ligar novo inquérito",
"mandatory_mapping_note": "Título do tópico e texto do componente são campos obrigatórios para configurar a integração Plain",
"map_formbricks_fields_to_plain": "Mapear respostas do Formbricks para campos Plain",
"no_contact_info_question": "Para poder criar um ticket, a Plain exige criar um novo cliente com nome, sobrenome e email. Precisamos capturar essa informação usando o tipo de pergunta Contato.",
"no_databases_found": "Nenhum inquérito conectado",
"plain_integration": "Integração com Plain",
"plain_integration_description": "Criar tópicos no Plain usando respostas do Formbricks",
"select_a_survey_question": "Selecione uma pergunta do inquérito",
"update_connection": "Reconectar Plain",
"update_connection_tooltip": "Atualizar a ligação Plain"
},
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {

View File

@@ -134,6 +134,8 @@
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
"api_key_label": "Plain API 金鑰",
"api_key_label_placeholder": "plainApiKey_xxxx",
"api_keys": "API 金鑰",
"app": "應用程式",
"app_survey": "應用程式問卷",
@@ -701,6 +703,29 @@
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"plain": {
"add_key": "新增 Plain API 金鑰",
"add_key_description": "添加你的 Plain API Key 以連線 Plain",
"api_key_label": "Plain API 金鑰",
"configure_plain_integration": "連結 調查 來 建立 Plain 線程",
"connect": "連結問卷",
"connect_with_plain": "連線 Plain",
"connection_success": "純連接成功",
"contact_info_all_present": "要 建立 工單 Plain 必須 建立 一個 新 客戶 包括 名、 姓 和 電子 郵件 。 我們 需要 使用 問題 類型 「聯絡資訊」 來 捕捉 這些 資訊 。",
"contact_info_missing_title": "此 調查 缺少 必要 問題。",
"contact_info_success_title": "此 調查 包含 所有 必要 問題。",
"enter_label_id": "輸入 Plain 的標籤 ID",
"link_new_database": "連結新問卷",
"mandatory_mapping_note": "線程標題 和 組件文本 是 設置 Plain 集成 的 必填字段",
"map_formbricks_fields_to_plain": "將 Formbricks 回應 映射到 Plain 欄位",
"no_contact_info_question": "要 建立 工單 Plain 必須 建立 一個 新 客戶 包括 名、 姓 和 電子 郵件 。 我們 需要 使用 問題 類型 「聯絡資訊」 來 捕捉 這些 資訊 。",
"no_databases_found": "未連結任何問卷",
"plain_integration": "Plain 整合",
"plain_integration_description": "使用 Formbricks 回應在 Plain 上建立 threads",
"select_a_survey_question": "選取問卷問題",
"update_connection": "重新連線 Plain",
"update_connection_tooltip": "更新 Plain 連接"
},
"please_select_a_survey_error": "請選取問卷",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {

View File

@@ -33,5 +33,12 @@ export const getIntegrationDetails = (integrationType: TIntegrationType, t: TFnT
connectButtonLabel: t("environments.integrations.slack.connect_with_slack"),
notConfiguredText: t("environments.integrations.slack.slack_integration_is_not_configured"),
};
case "plain":
return {
text: t("environments.integrations.plain.plain_integration_description"),
docsLink: "https://formbricks.com/docs/integrations/plain",
connectButtonLabel: t("environments.integrations.plain.connect_with_plain"),
notConfiguredText: t("environments.integrations.plain.plain_integration_is_not_configured"),
};
}
};

View File

@@ -76,6 +76,7 @@
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@team-plain/typescript-sdk": "5.9.0",
"@tolgee/format-icu": "6.2.5",
"@tolgee/react": "6.2.5",
"@ungap/structured-clone": "1.3.0",

View File

@@ -484,6 +484,7 @@ enum IntegrationType {
notion
airtable
slack
plain
}
/// Represents third-party service integrations.

View File

@@ -2,9 +2,10 @@ import { z } from "zod";
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./google-sheet";
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
import { ZIntegrationPlainConfig, ZIntegrationPlainInput } from "./plain";
import { ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack"]);
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack", "plain"]);
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
export const ZIntegrationConfig = z.union([
@@ -12,6 +13,7 @@ export const ZIntegrationConfig = z.union([
ZIntegrationAirtableConfig,
ZIntegrationNotionConfig,
ZIntegrationSlackConfig,
ZIntegrationPlainConfig,
]);
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
@@ -41,6 +43,7 @@ export const ZIntegrationInput = z.discriminatedUnion("type", [
ZIntegrationAirtableInput,
ZIntegrationNotionInput,
ZIntegrationSlackInput,
ZIntegrationPlainInput,
]);
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
import { ZIntegrationBase, ZIntegrationBaseSurveyData } from "./shared-types";
export const ZIntegrationPlainCredential = z.string().min(1);
export type TIntegrationPlainCredential = z.infer<typeof ZIntegrationPlainCredential>;
// Define Plain field types
export const ZPlainFieldType = z.enum([
"componentText",
"firstName",
"lastName",
"title",
"customerIdentifier",
"labelTypeId",
]);
export type TPlainFieldType = z.infer<typeof ZPlainFieldType>;
// Define Plain mapping type
export const ZPlainMapping = z.object({
question: z.object({
id: z.string(),
name: z.string(),
type: z.string(),
}),
plainField: z.object({
id: z.string(),
name: z.string(),
type: ZPlainFieldType,
config: z.record(z.any()).optional(),
}),
});
export type TPlainMapping = z.infer<typeof ZPlainMapping>;
export const ZIntegrationPlainConfigData = z
.object({
// question -> plain thread mapping
mapping: z.array(ZPlainMapping),
labelId: z.string().optional(),
includeCreatedAt: z.boolean().default(true),
includeComponents: z.boolean().default(true),
})
.merge(
ZIntegrationBaseSurveyData.omit({
questionIds: true,
questions: true,
})
);
export type TIntegrationPlainConfigData = z.infer<typeof ZIntegrationPlainConfigData>;
export const ZIntegrationPlainConfig = z.object({
key: ZIntegrationPlainCredential,
data: z.array(ZIntegrationPlainConfigData),
});
export type TIntegrationPlainConfig = z.infer<typeof ZIntegrationPlainConfig>;
export const ZIntegrationPlain = ZIntegrationBase.extend({
type: z.literal("plain"),
config: ZIntegrationPlainConfig,
});
export type TIntegrationPlain = z.infer<typeof ZIntegrationPlain>;
export const ZIntegrationPlainInput = z.object({
type: z.literal("plain"),
config: ZIntegrationPlainConfig,
});
export type TIntegrationPlainInput = z.infer<typeof ZIntegrationPlainInput>;
export const ZIntegrationPlainDatabase = z.object({
id: z.string(),
name: z.string(),
properties: z.object({}),
});
export type TIntegrationPlainDatabase = z.infer<typeof ZIntegrationPlainDatabase>;
export interface TPlainThreadInput {
customerIdentifier: Record<string, string>;
title: string;
components: string[];
fields?: Record<string, string>;
labelIds?: string[];
}

41
pnpm-lock.yaml generated
View File

@@ -268,6 +268,9 @@ importers:
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@team-plain/typescript-sdk':
specifier: 5.9.0
version: 5.9.0
'@tolgee/format-icu':
specifier: 6.2.5
version: 6.2.5
@@ -1632,6 +1635,11 @@ packages:
resolution: {integrity: sha512-Fc1FzKPLSavcvicNIeeMtdlwfI/ZbNHqI+9D/CEVgkTUaIiDSP2YX3j7vGMzeSgD+BQuoAPR7eaT5UMl91k84Q==}
engines: {node: '>=12.0.0'}
'@graphql-typed-document-node/core@3.2.0':
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
'@grpc/grpc-js@1.12.6':
resolution: {integrity: sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q==}
engines: {node: '>=12.10.0'}
@@ -3866,6 +3874,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@team-plain/typescript-sdk@5.9.0':
resolution: {integrity: sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==}
'@tediousjs/connection-string@0.5.0':
resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==}
@@ -6321,6 +6332,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
graphql@16.11.0:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
gtoken@7.1.0:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
@@ -6955,6 +6970,10 @@ packages:
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -9691,6 +9710,9 @@ packages:
peerDependencies:
zod: ^3.21.4
zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
@@ -11277,6 +11299,10 @@ snapshots:
- encoding
- supports-color
'@graphql-typed-document-node/core@3.2.0(graphql@16.11.0)':
dependencies:
graphql: 16.11.0
'@grpc/grpc-js@1.12.6':
dependencies:
'@grpc/proto-loader': 0.7.15
@@ -13826,6 +13852,15 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@team-plain/typescript-sdk@5.9.0':
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0)
ajv: 8.17.1
ajv-formats: 2.1.1(ajv@8.17.1)
graphql: 16.11.0
lodash.get: 4.4.2
zod: 3.22.4
'@tediousjs/connection-string@0.5.0': {}
'@testing-library/dom@8.20.1':
@@ -16709,6 +16744,8 @@ snapshots:
graphemer@1.4.0: {}
graphql@16.11.0: {}
gtoken@7.1.0(encoding@0.1.13):
dependencies:
gaxios: 6.7.1(encoding@0.1.13)
@@ -17382,6 +17419,8 @@ snapshots:
lodash.castarray@4.4.0: {}
lodash.get@4.4.2: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@@ -20276,4 +20315,6 @@ snapshots:
dependencies:
zod: 3.24.4
zod@3.22.4: {}
zod@3.24.4: {}