mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-04 04:40:37 -06:00
Compare commits
32 Commits
fix/attrib
...
harsh/plai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2343cb60c | ||
|
|
e10a42f61e | ||
|
|
1319ca648c | ||
|
|
f6a544d01e | ||
|
|
697c132581 | ||
|
|
a53e9a1bee | ||
|
|
64fd5e40d7 | ||
|
|
b3886014cb | ||
|
|
44c5bec535 | ||
|
|
416f142385 | ||
|
|
d3d9e3223d | ||
|
|
130ed59677 | ||
|
|
7d8d7fc744 | ||
|
|
721ae66811 | ||
|
|
af6d9542e4 | ||
|
|
71b408e066 | ||
|
|
c7277bb709 | ||
|
|
04a709c6c2 | ||
|
|
4ee0b9ec03 | ||
|
|
eb8eac8aa4 | ||
|
|
c1444f8427 | ||
|
|
15adaf6976 | ||
|
|
85fababd57 | ||
|
|
3694f93429 | ||
|
|
36e0e62f01 | ||
|
|
6d2bd9210c | ||
|
|
636374ae04 | ||
|
|
b0627fffa5 | ||
|
|
84a94ad027 | ||
|
|
55a1b95988 | ||
|
|
bdf1698c05 | ||
|
|
c761f51b0e |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
|
||||||
import {
|
import {
|
||||||
ERRORS,
|
ERRORS,
|
||||||
TYPE_MAPPING,
|
TYPE_MAPPING,
|
||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
|
||||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +34,7 @@ import {
|
|||||||
TIntegrationNotionConfigData,
|
TIntegrationNotionConfigData,
|
||||||
TIntegrationNotionDatabase,
|
TIntegrationNotionDatabase,
|
||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
interface AddIntegrationModalProps {
|
interface AddIntegrationModalProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
@@ -118,47 +117,7 @@ export const AddIntegrationModal = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDatabase?.id]);
|
}, [selectedDatabase?.id]);
|
||||||
|
|
||||||
const questionItems = useMemo(() => {
|
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
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 { useTranslate } from "@tolgee/react";
|
||||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
@@ -14,6 +12,7 @@ import toast from "react-hot-toast";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
@@ -70,78 +69,58 @@ export const ManageIntegration = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<IntegrationListPanel
|
||||||
<div className="mr-6 flex items-center">
|
environment={environment}
|
||||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
statusNode={
|
||||||
<span className="text-slate-500">
|
<>
|
||||||
{t("environments.integrations.notion.connected_with_workspace", {
|
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||||
workspace: notionIntegration.config.key.workspace_name,
|
<span className="text-slate-500">
|
||||||
})}
|
{t("environments.integrations.notion.connected_with_workspace", {
|
||||||
</span>
|
workspace: notionIntegration.config.key.workspace_name,
|
||||||
</div>
|
})}
|
||||||
<TooltipProvider>
|
</span>
|
||||||
<Tooltip>
|
</>
|
||||||
<TooltipTrigger asChild>
|
}
|
||||||
<Button variant="outline" onClick={handleNotionAuthorization}>
|
reconnectAction={{
|
||||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
label: t("environments.integrations.notion.update_connection"),
|
||||||
{t("environments.integrations.notion.update_connection")}
|
onClick: handleNotionAuthorization,
|
||||||
</Button>
|
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
|
||||||
</TooltipTrigger>
|
tooltip: t("environments.integrations.notion.update_connection_tooltip"),
|
||||||
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
|
variant: "outline",
|
||||||
</Tooltip>
|
}}
|
||||||
</TooltipProvider>
|
addNewAction={{
|
||||||
<Button
|
label: t("environments.integrations.notion.link_new_database"),
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
setSelectedIntegration(null);
|
setSelectedIntegration(null);
|
||||||
setOpenAddIntegrationModal(true);
|
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>
|
</Button>
|
||||||
</div>
|
</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
|
<DeleteDialog
|
||||||
open={isDeleteIntegrationModalOpen}
|
open={isDeleteIntegrationModalOpen}
|
||||||
@@ -151,6 +130,6 @@ export const ManageIntegration = ({
|
|||||||
text={t("environments.integrations.delete_integration_confirmation")}
|
text={t("environments.integrations.delete_integration_confirmation")}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import JsLogo from "@/images/jslogo.png";
|
|||||||
import MakeLogo from "@/images/make-small.png";
|
import MakeLogo from "@/images/make-small.png";
|
||||||
import n8nLogo from "@/images/n8n.png";
|
import n8nLogo from "@/images/n8n.png";
|
||||||
import notionLogo from "@/images/notion.png";
|
import notionLogo from "@/images/notion.png";
|
||||||
|
import PlainCom from "@/images/plain.webp";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import WebhookLogo from "@/images/webhook.png";
|
import WebhookLogo from "@/images/webhook.png";
|
||||||
import ZapierLogo from "@/images/zapier-small.png";
|
import ZapierLogo from "@/images/zapier-small.png";
|
||||||
@@ -50,6 +51,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||||
const isNotionIntegrationConnected = isIntegrationConnected("notion");
|
const isNotionIntegrationConnected = isIntegrationConnected("notion");
|
||||||
|
const isPlainIntegrationConnected = isIntegrationConnected("plain");
|
||||||
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
|
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
|
||||||
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
|
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
|
||||||
const isSlackIntegrationConnected = isIntegrationConnected("slack");
|
const isSlackIntegrationConnected = isIntegrationConnected("slack");
|
||||||
@@ -207,6 +209,20 @@ const Page = async (props) => {
|
|||||||
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
|
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
|
||||||
disabled: isReadOnly,
|
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({
|
integrationCards.unshift({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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("./");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -4,6 +4,7 @@ import { NOTION_RICH_TEXT_LIMIT } from "@/lib/constants";
|
|||||||
import { writeData } from "@/lib/googleSheet/service";
|
import { writeData } from "@/lib/googleSheet/service";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { writeData as writeNotionData } from "@/lib/notion/service";
|
import { writeData as writeNotionData } from "@/lib/notion/service";
|
||||||
|
import { writeData as writeDataToPlain } from "@/lib/plain/service";
|
||||||
import { processResponseData } from "@/lib/responses";
|
import { processResponseData } from "@/lib/responses";
|
||||||
import { writeDataToSlack } from "@/lib/slack/service";
|
import { writeDataToSlack } from "@/lib/slack/service";
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
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 { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
|
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { TResponseMeta } from "@formbricks/types/responses";
|
import { TResponseMeta } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
@@ -109,6 +111,13 @@ export const handleIntegrations = async (
|
|||||||
logger.error(notionResult.error, "Error in notion integration");
|
logger.error(notionResult.error, "Error in notion integration");
|
||||||
}
|
}
|
||||||
break;
|
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!");
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const GET = async (req: NextRequest) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tokenData = await response.json();
|
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;
|
tokenData.access_token = encryptedAccessToken;
|
||||||
|
|
||||||
const notionIntegration: TIntegrationNotionInput = {
|
const notionIntegration: TIntegrationNotionInput = {
|
||||||
|
|||||||
BIN
apps/web/images/plain.webp
Normal file
BIN
apps/web/images/plain.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -61,7 +61,7 @@ export const writeData = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getHeaders = (config: TIntegrationNotionConfig) => {
|
const getHeaders = (config: TIntegrationNotionConfig) => {
|
||||||
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY!);
|
const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY);
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
283
apps/web/lib/plain/service.test.ts
Normal file
283
apps/web/lib/plain/service.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/web/lib/plain/service.ts
Normal file
108
apps/web/lib/plain/service.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
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 { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||||
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
||||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||||
import { truncateText } from "../utils/strings";
|
import { truncateText } from "../utils/strings";
|
||||||
|
|
||||||
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
|
export const fetchChannels = async (slackIntegration: TIntegrationSlack): Promise<TIntegrationItem[]> => {
|
||||||
let channels: TIntegrationItem[] = [];
|
let channels: TIntegrationItem[] = [];
|
||||||
// `nextCursor` is a pagination token returned by the Slack API. It indicates the presence of additional pages of data.
|
// `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.
|
// When `nextCursor` is not empty, it should be included in subsequent requests to fetch the next page of data.
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "und",
|
"and": "und",
|
||||||
"and_response_limit_of": "und Antwortlimit von",
|
"and_response_limit_of": "und Antwortlimit von",
|
||||||
"anonymous": "Anonym",
|
"anonymous": "Anonym",
|
||||||
|
"api_key_label": "Plain-API-Schlüssel",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "API-Schlüssel",
|
"api_keys": "API-Schlüssel",
|
||||||
"app": "App",
|
"app": "App",
|
||||||
"app_survey": "App-Umfrage",
|
"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."
|
"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",
|
"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",
|
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
|
||||||
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "And",
|
"and": "And",
|
||||||
"and_response_limit_of": "and response limit of",
|
"and_response_limit_of": "and response limit of",
|
||||||
"anonymous": "Anonymous",
|
"anonymous": "Anonymous",
|
||||||
|
"api_key_label": "Plain API key",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
"app": "App",
|
"app": "App",
|
||||||
"app_survey": "App Survey",
|
"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."
|
"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",
|
"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",
|
"please_select_a_survey_error": "Please select a survey",
|
||||||
"select_at_least_one_question_error": "Please select at least one question",
|
"select_at_least_one_question_error": "Please select at least one question",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "Et",
|
"and": "Et",
|
||||||
"and_response_limit_of": "et limite de réponse de",
|
"and_response_limit_of": "et limite de réponse de",
|
||||||
"anonymous": "Anonyme",
|
"anonymous": "Anonyme",
|
||||||
|
"api_key_label": "Clé API Plain",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "Clés API",
|
"api_keys": "Clés API",
|
||||||
"app": "Application",
|
"app": "Application",
|
||||||
"app_survey": "Sondage d'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."
|
"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",
|
"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.",
|
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
|
||||||
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "E",
|
"and": "E",
|
||||||
"and_response_limit_of": "e limite de resposta de",
|
"and_response_limit_of": "e limite de resposta de",
|
||||||
"anonymous": "Anônimo",
|
"anonymous": "Anônimo",
|
||||||
|
"api_key_label": "Chave API do Plain",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "Chaves de API",
|
"api_keys": "Chaves de API",
|
||||||
"app": "app",
|
"app": "app",
|
||||||
"app_survey": "Pesquisa de 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."
|
"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",
|
"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",
|
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
|
||||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "E",
|
"and": "E",
|
||||||
"and_response_limit_of": "e limite de resposta de",
|
"and_response_limit_of": "e limite de resposta de",
|
||||||
"anonymous": "Anónimo",
|
"anonymous": "Anónimo",
|
||||||
|
"api_key_label": "Chave API do Plain",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "Chaves API",
|
"api_keys": "Chaves API",
|
||||||
"app": "Aplicação",
|
"app": "Aplicação",
|
||||||
"app_survey": "Inquérito da 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."
|
"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",
|
"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",
|
"please_select_a_survey_error": "Por favor, selecione um inquérito",
|
||||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
"and": "且",
|
"and": "且",
|
||||||
"and_response_limit_of": "且回應上限為",
|
"and_response_limit_of": "且回應上限為",
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
|
"api_key_label": "Plain API 金鑰",
|
||||||
|
"api_key_label_placeholder": "plainApiKey_xxxx",
|
||||||
"api_keys": "API 金鑰",
|
"api_keys": "API 金鑰",
|
||||||
"app": "應用程式",
|
"app": "應用程式",
|
||||||
"app_survey": "應用程式問卷",
|
"app_survey": "應用程式問卷",
|
||||||
@@ -701,6 +703,29 @@
|
|||||||
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
|
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
|
||||||
},
|
},
|
||||||
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
|
"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": "請選取問卷",
|
"please_select_a_survey_error": "請選取問卷",
|
||||||
"select_at_least_one_question_error": "請選取至少一個問題",
|
"select_at_least_one_question_error": "請選取至少一個問題",
|
||||||
"slack": {
|
"slack": {
|
||||||
|
|||||||
@@ -33,5 +33,12 @@ export const getIntegrationDetails = (integrationType: TIntegrationType, t: TFnT
|
|||||||
connectButtonLabel: t("environments.integrations.slack.connect_with_slack"),
|
connectButtonLabel: t("environments.integrations.slack.connect_with_slack"),
|
||||||
notConfiguredText: t("environments.integrations.slack.slack_integration_is_not_configured"),
|
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"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@team-plain/typescript-sdk": "5.9.0",
|
||||||
"@tolgee/format-icu": "6.2.5",
|
"@tolgee/format-icu": "6.2.5",
|
||||||
"@tolgee/react": "6.2.5",
|
"@tolgee/react": "6.2.5",
|
||||||
"@ungap/structured-clone": "1.3.0",
|
"@ungap/structured-clone": "1.3.0",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ enum IntegrationType {
|
|||||||
notion
|
notion
|
||||||
airtable
|
airtable
|
||||||
slack
|
slack
|
||||||
|
plain
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents third-party service integrations.
|
/// Represents third-party service integrations.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { z } from "zod";
|
|||||||
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
|
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
|
||||||
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./google-sheet";
|
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./google-sheet";
|
||||||
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
|
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
|
||||||
|
import { ZIntegrationPlainConfig, ZIntegrationPlainInput } from "./plain";
|
||||||
import { ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
|
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 type TIntegrationType = z.infer<typeof ZIntegrationType>;
|
||||||
|
|
||||||
export const ZIntegrationConfig = z.union([
|
export const ZIntegrationConfig = z.union([
|
||||||
@@ -12,6 +13,7 @@ export const ZIntegrationConfig = z.union([
|
|||||||
ZIntegrationAirtableConfig,
|
ZIntegrationAirtableConfig,
|
||||||
ZIntegrationNotionConfig,
|
ZIntegrationNotionConfig,
|
||||||
ZIntegrationSlackConfig,
|
ZIntegrationSlackConfig,
|
||||||
|
ZIntegrationPlainConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
|
export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
|
||||||
@@ -41,6 +43,7 @@ export const ZIntegrationInput = z.discriminatedUnion("type", [
|
|||||||
ZIntegrationAirtableInput,
|
ZIntegrationAirtableInput,
|
||||||
ZIntegrationNotionInput,
|
ZIntegrationNotionInput,
|
||||||
ZIntegrationSlackInput,
|
ZIntegrationSlackInput,
|
||||||
|
ZIntegrationPlainInput,
|
||||||
]);
|
]);
|
||||||
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;
|
export type TIntegrationInput = z.infer<typeof ZIntegrationInput>;
|
||||||
|
|
||||||
|
|||||||
89
packages/types/integration/plain.ts
Normal file
89
packages/types/integration/plain.ts
Normal 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
41
pnpm-lock.yaml
generated
@@ -268,6 +268,9 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: 8.21.3
|
specifier: 8.21.3
|
||||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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':
|
'@tolgee/format-icu':
|
||||||
specifier: 6.2.5
|
specifier: 6.2.5
|
||||||
version: 6.2.5
|
version: 6.2.5
|
||||||
@@ -1632,6 +1635,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Fc1FzKPLSavcvicNIeeMtdlwfI/ZbNHqI+9D/CEVgkTUaIiDSP2YX3j7vGMzeSgD+BQuoAPR7eaT5UMl91k84Q==}
|
resolution: {integrity: sha512-Fc1FzKPLSavcvicNIeeMtdlwfI/ZbNHqI+9D/CEVgkTUaIiDSP2YX3j7vGMzeSgD+BQuoAPR7eaT5UMl91k84Q==}
|
||||||
engines: {node: '>=12.0.0'}
|
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':
|
'@grpc/grpc-js@1.12.6':
|
||||||
resolution: {integrity: sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q==}
|
resolution: {integrity: sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q==}
|
||||||
engines: {node: '>=12.10.0'}
|
engines: {node: '>=12.10.0'}
|
||||||
@@ -3866,6 +3874,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@team-plain/typescript-sdk@5.9.0':
|
||||||
|
resolution: {integrity: sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==}
|
||||||
|
|
||||||
'@tediousjs/connection-string@0.5.0':
|
'@tediousjs/connection-string@0.5.0':
|
||||||
resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==}
|
resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==}
|
||||||
|
|
||||||
@@ -6321,6 +6332,10 @@ packages:
|
|||||||
graphemer@1.4.0:
|
graphemer@1.4.0:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
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:
|
gtoken@7.1.0:
|
||||||
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
|
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -6955,6 +6970,10 @@ packages:
|
|||||||
lodash.castarray@4.4.0:
|
lodash.castarray@4.4.0:
|
||||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
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:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
@@ -9691,6 +9710,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.21.4
|
zod: ^3.21.4
|
||||||
|
|
||||||
|
zod@3.22.4:
|
||||||
|
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
||||||
|
|
||||||
zod@3.24.4:
|
zod@3.24.4:
|
||||||
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
|
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
|
||||||
|
|
||||||
@@ -11277,6 +11299,10 @@ snapshots:
|
|||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- 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':
|
'@grpc/grpc-js@1.12.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@grpc/proto-loader': 0.7.15
|
'@grpc/proto-loader': 0.7.15
|
||||||
@@ -13826,6 +13852,15 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@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': {}
|
'@tediousjs/connection-string@0.5.0': {}
|
||||||
|
|
||||||
'@testing-library/dom@8.20.1':
|
'@testing-library/dom@8.20.1':
|
||||||
@@ -16709,6 +16744,8 @@ snapshots:
|
|||||||
|
|
||||||
graphemer@1.4.0: {}
|
graphemer@1.4.0: {}
|
||||||
|
|
||||||
|
graphql@16.11.0: {}
|
||||||
|
|
||||||
gtoken@7.1.0(encoding@0.1.13):
|
gtoken@7.1.0(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
gaxios: 6.7.1(encoding@0.1.13)
|
gaxios: 6.7.1(encoding@0.1.13)
|
||||||
@@ -17382,6 +17419,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.castarray@4.4.0: {}
|
lodash.castarray@4.4.0: {}
|
||||||
|
|
||||||
|
lodash.get@4.4.2: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
@@ -20276,4 +20315,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.24.4
|
zod: 3.24.4
|
||||||
|
|
||||||
|
zod@3.22.4: {}
|
||||||
|
|
||||||
zod@3.24.4: {}
|
zod@3.24.4: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user