feat: Unify POC hackathon (#7169)

Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Harsh Bhat
2026-02-24 22:29:28 +05:30
committed by GitHub
parent 337aedf463
commit 75e71e39bc
76 changed files with 6863 additions and 62 deletions
@@ -9,6 +9,7 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -99,7 +100,7 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
name: t("common.ask"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
@@ -107,7 +108,7 @@ export const MainNavigation = ({
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
name: t("common.distribute"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
@@ -115,7 +116,13 @@ export const MainNavigation = ({
pathname?.includes("/attributes"),
},
{
name: t("common.configuration"),
name: t("common.unify"),
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
},
{
name: t("common.configure"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
@@ -188,7 +195,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `/environments/${currentEnvironmentId}/workspace/unify`,
},
];
if (!currentProject) {
@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { environmentId } = params;
if (environmentId === "undefined") {
return redirect("/");
}
const { children } = props;
// Check session first (required for userId)
@@ -280,7 +280,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
@@ -0,0 +1,25 @@
"use client";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
environmentId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
environmentId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const { t } = useTranslation();
const baseHref = `/environments/${environmentId}/workspace/unify`;
const activeId = activeIdProp ?? "sources";
const navigation = [{ id: "sources", label: t("environments.unify.sources"), href: `${baseHref}/sources` }];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/sources`);
}
@@ -0,0 +1,38 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { getSurveys } from "@/lib/survey/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 { transformToUnifySurvey } from "./lib";
import { TUnifySurvey } from "./types";
const ZGetSurveysForUnifyAction = z.object({
environmentId: ZId,
});
export const getSurveysForUnifyAction = authenticatedActionClient
.schema(ZGetSurveysForUnifyAction)
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
const surveys = await getSurveys(parsedInput.environmentId);
return surveys.map((survey) => transformToUnifySurvey(survey));
});
@@ -0,0 +1,56 @@
"use client";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorType | null;
onSelectType: (type: TConnectorType) => void;
}
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("environments.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
import {
createConnectorWithMappingsAction,
deleteConnectorAction,
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { TFieldMapping, TUnifySurvey } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { EditConnectorModal } from "./edit-connector-modal";
interface ConnectorsSectionProps {
environmentId: string;
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
}
export function ConnectorsSection({
environmentId,
initialConnectors,
initialSurveys,
}: ConnectorsSectionProps) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await createConnectorWithMappingsAction({
environmentId,
connectorInput: {
name: data.name,
type: data.type,
},
formbricksMappings:
data.type === "formbricks" && data.surveyId && data.elementIds?.length
? { surveyId: data.surveyId, elementIds: data.elementIds }
: undefined,
fieldMappings:
data.type !== "formbricks" && data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
staticValue: m.staticValue,
}))
: undefined,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_created_successfully"));
router.refresh();
};
const handleUpdateConnector = async (data: {
connectorId: string;
environmentId: string;
name: string;
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
environmentId,
connectorInput: {
name: data.name,
},
formbricksMappings:
data.surveyId && data.elementIds?.length
? { surveyId: data.surveyId, elementIds: data.elementIds }
: undefined,
fieldMappings: data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
staticValue: m.staticValue,
}))
: undefined,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_updated_successfully"));
router.refresh();
};
const handleDeleteConnector = async (connectorId: string) => {
const result = await deleteConnectorAction({ connectorId, environmentId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_deleted_successfully"));
router.refresh();
};
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("environments.unify.unify_feedback")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
isLoading={false}
/>
</div>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
onDeleteConnector={handleDeleteConnector}
surveys={initialSurveys}
/>
</PageContentWrapper>
);
}
@@ -0,0 +1,124 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: "seconds" },
{ amount: 60, unit: "minutes" },
{ amount: 24, unit: "hours" },
{ amount: 7, unit: "days" },
{ amount: 4.345, unit: "weeks" },
{ amount: 12, unit: "months" },
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
];
function getRelativeTime(date: Date, locale: string): string {
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
let duration = (date.getTime() - Date.now()) / 1000;
for (const division of RELATIVE_TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
return formatter.format(Math.round(duration), "years");
}
interface ConnectorsTableDataRowProps {
id: string;
name: string;
type: TConnectorType;
status: TConnectorStatus;
mappingsCount: number;
createdAt: Date;
onClick: () => void;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
error: "error",
};
export function ConnectorsTableDataRow({
id,
name,
type,
status,
mappingsCount,
createdAt,
onClick,
}: ConnectorsTableDataRowProps) {
const { t, i18n } = useTranslation();
const getStatusLabel = (s: TConnectorStatus) => {
switch (s) {
case "active":
return t("environments.unify.status_active");
case "paused":
return t("environments.unify.status_paused");
case "error":
return t("environments.unify.status_error");
}
};
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
switch (connectorType) {
case "formbricks":
return t("environments.unify.formbricks_surveys");
case "csv":
return t("environments.unify.csv_import");
default:
return connectorType;
}
};
return (
<div
key={id}
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
}
}}>
<div className="col-span-2 flex items-center gap-2 pl-4">
{getConnectorIcon(type)}
<span className="hidden truncate text-xs text-slate-500 sm:inline">
{getConnectorTypeLabel(type)}
</span>
</div>
<div className="col-span-4 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{name}</span>
</div>
<div className="col-span-2 hidden items-center justify-center sm:flex">
<Badge text={getStatusLabel(status)} type={STATUS_BADGE_TYPE[status]} size="tiny" />
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{mappingsCount} {mappingsCount === 1 ? t("environments.unify.field") : t("environments.unify.fields")}
</div>
<div className="col-span-2 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
{getRelativeTime(createdAt, i18n.language)}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableDataRow } from "./connectors-table-data-row";
interface ConnectorsTableProps {
connectors: TConnectorWithMappings[];
onConnectorClick: (connector: TConnectorWithMappings) => void;
isLoading?: boolean;
}
export function ConnectorsTable({ connectors, onConnectorClick, isLoading = false }: ConnectorsTableProps) {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 pl-6">{t("common.type")}</div>
<div className="col-span-4">{t("common.name")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.mappings")}</div>
<div className="col-span-2 hidden pr-6 text-right sm:block">{t("common.created")}</div>
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
</div>
) : connectors.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("environments.unify.no_sources_connected")}</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{connectors.map((connector) => (
<ConnectorsTableDataRow
key={connector.id}
id={connector.id}
name={connector.name}
type={connector.type}
status={connector.status}
mappingsCount={connector.formbricksMappings.length + connector.fieldMappings.length}
createdAt={connector.createdAt}
onClick={() => onConnectorClick(connector)}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
TCreateConnectorStep,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
}
export const CreateConnectorModal = ({
open,
onOpenChange,
onCreateConnector,
surveys,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const defaultConnectorName: Record<TConnectorType, string> = {
formbricks: t("environments.unify.default_connector_name_formbricks"),
csv: t("environments.unify.default_connector_name_csv"),
};
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setConnectorName("");
setMappings([]);
setSourceFields([]);
setSelectedSurveyId(null);
setSelectedElementIds([]);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep === "selectType" && selectedType) {
if (selectedType === "formbricks") {
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
setConnectorName(
selectedSurvey
? `${selectedSurvey.name} ${t("environments.unify.connection")}`
: defaultConnectorName[selectedType]
);
} else {
setConnectorName(defaultConnectorName[selectedType]);
}
setCurrentStep("mapping");
}
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(survey.elements.map((e) => e.id));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
}
};
const handleCreate = async () => {
if (!selectedType || !connectorName.trim()) return;
if (selectedType !== "formbricks") {
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequired = requiredFields.every((field) => mappings.some((m) => m.targetFieldId === field.id));
if (!allRequired) {
console.warn("Not all required fields are mapped");
}
}
await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
surveyId: selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
elementIds: selectedType === "formbricks" ? selectedElementIds : undefined,
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
resetForm();
onOpenChange(false);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
const isFormbricksValid =
selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0;
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
setSourceFields(fields);
}
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
{t("environments.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{currentStep === "selectType"
? t("environments.unify.add_feedback_source")
: selectedType === "formbricks"
? t("environments.unify.select_survey_and_questions")
: selectedType === "csv"
? t("environments.unify.import_csv_data")
: t("environments.unify.configure_mapping")}
</DialogTitle>
<DialogDescription>
{currentStep === "selectType"
? t("environments.unify.select_source_type_description")
: selectedType === "formbricks"
? t("environments.unify.select_survey_questions_description")
: selectedType === "csv"
? t("environments.unify.upload_csv_data_description")
: t("environments.unify.configure_mapping")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{currentStep === "selectType" ? (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
) : selectedType === "formbricks" ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
</div>
) : selectedType === "csv" ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<CsvConnectorUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
/>
</div>
</div>
) : null}
</div>
<DialogFooter>
{currentStep === "mapping" && (
<Button variant="outline" onClick={handleBack}>
{t("common.back")}
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
{selectedType === "formbricks"
? t("environments.unify.select_questions")
: selectedType === "csv"
? t("environments.unify.configure_import")
: t("environments.unify.create_mapping")}
</Button>
) : (
<Button
onClick={handleCreate}
disabled={
!connectorName.trim() ||
(selectedType === "formbricks"
? !isFormbricksValid
: selectedType === "csv"
? !isCsvValid
: !allRequiredMapped)
}>
{t("environments.unify.setup_connection")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,358 @@
"use client";
import { parse } from "csv-parse/sync";
import {
ArrowUpFromLineIcon,
CloudIcon,
CopyIcon,
FolderIcon,
RefreshCwIcon,
SettingsIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import { MappingUI } from "./mapping-ui";
interface CsvConnectorUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
onSourceFieldsChange: (fields: TSourceField[]) => void;
onLoadSampleCSV: () => void;
}
export function CsvConnectorUI({
sourceFields,
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
}: CsvConnectorUIProps) {
const { t } = useTranslation();
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [showMapping, setShowMapping] = useState(false);
const [csvError, setCsvError] = useState("");
const [s3AutoSync, setS3AutoSync] = useState(false);
const [s3Copied, setS3Copied] = useState(false);
const s3BucketName = "formbricks-feedback-imports";
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
const handleCopyS3Path = () => {
navigator.clipboard.writeText(s3Path);
setS3Copied(true);
setTimeout(() => setS3Copied(false), 2000);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) {
processCSVFile(file);
}
};
const processCSVFile = (file: File) => {
setCsvError("");
if (!file.name.endsWith(".csv")) {
setCsvError(t("environments.unify.csv_files_only"));
return;
}
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
setCsvError(t("environments.unify.csv_files_only"));
return;
}
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
setCsvError(t("environments.unify.csv_file_too_large"));
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const csv = e.target?.result as string;
try {
const records = parse(csv, { columns: true, skip_empty_lines: true });
const result = createFeedbackCSVDataSchema(t).safeParse(records);
if (!result.success) {
setCsvError(result.error.errors[0].message);
return;
}
const validRecords = result.data;
const headers = Object.keys(validRecords[0]);
const preview: string[][] = [
headers,
...validRecords.slice(0, 5).map((row) => headers.map((h) => row[h] ?? "")),
];
setCsvFile(file);
setCsvPreview(preview);
const fields: TSourceField[] = headers.map((header) => ({
id: header,
name: header,
type: "string",
sampleValue: validRecords[0][header] ?? "",
}));
onSourceFieldsChange(fields);
setShowMapping(true);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to parse CSV";
setCsvError(message);
}
};
reader.readAsText(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
processCSVFile(file);
}
};
const handleLoadSample = () => {
onLoadSampleCSV();
setShowMapping(true);
};
if (showMapping && sourceFields.length > 0) {
return (
<div className="space-y-4">
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
<div className="flex items-center gap-2">
<FolderIcon className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvError("");
setShowMapping(false);
onSourceFieldsChange([]);
}}>
{t("environments.unify.change_file")}
</Button>
</div>
)}
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{csvPreview.length > 4 && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
{t("environments.unify.showing_rows", { count: csvPreview.length - 1 })}
</div>
)}
</div>
)}
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
connectorType="csv"
/>
</div>
);
}
return (
<div className="space-y-6">
{csvError && (
<Alert variant="error" size="small">
{csvError}
</Alert>
)}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.upload_csv_file")}</h4>
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
<label
htmlFor="csv-file-upload"
className="flex cursor-pointer flex-col items-center justify-center"
onDragOver={handleDragOver}
onDrop={handleDrop}>
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">{t("environments.unify.click_to_upload")}</span>{" "}
{t("environments.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("environments.unify.csv_files_only")}</p>
<input
type="file"
id="csv-file-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
<div className="flex justify-between">
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
{t("environments.unify.load_sample_csv")}
</Button>
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-slate-200" />
<span className="text-xs font-medium uppercase text-slate-400">{t("environments.unify.or")}</span>
<div className="h-px flex-1 bg-slate-200" />
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<CloudIcon className="h-5 w-5 text-slate-500" />
<h4 className="text-sm font-medium text-slate-700">
{t("environments.unify.s3_bucket_integration")}
</h4>
<Badge text={t("environments.unify.automated")} type="gray" size="tiny" />
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<p className="mb-4 text-sm text-slate-600">{t("environments.unify.s3_bucket_description")}</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">{t("environments.unify.drop_zone_path")}</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
{s3Path}
</code>
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
<CopyIcon className="h-4 w-4" />
{s3Copied ? t("environments.unify.copied") : t("environments.unify.copy")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">{t("environments.unify.aws_region")}</Label>
<Select defaultValue="eu-central-1">
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="us-east-1">{t("environments.unify.region_us_east_1")}</SelectItem>
<SelectItem value="us-west-2">{t("environments.unify.region_us_west_2")}</SelectItem>
<SelectItem value="eu-central-1">
{t("environments.unify.region_eu_central_1")}
</SelectItem>
<SelectItem value="eu-west-1">{t("environments.unify.region_eu_west_1")}</SelectItem>
<SelectItem value="ap-southeast-1">
{t("environments.unify.region_ap_southeast_1")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("environments.unify.processing_interval")}</Label>
<Select defaultValue="15">
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">{t("environments.unify.every_5_minutes")}</SelectItem>
<SelectItem value="15">{t("environments.unify.every_15_minutes")}</SelectItem>
<SelectItem value="30">{t("environments.unify.every_30_minutes")}</SelectItem>
<SelectItem value="60">{t("environments.unify.every_hour")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-slate-900">
{t("environments.unify.enable_auto_sync")}
</span>
<span className="text-xs text-slate-500">
{t("environments.unify.process_new_files_description")}
</span>
</div>
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="flex items-start gap-2">
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
<div>
<p className="text-sm font-medium text-amber-800">
{t("environments.unify.iam_configuration_required")}
</p>
<p className="mt-1 text-xs text-amber-700">
{t("environments.unify.iam_setup_instructions")}{" "}
<button type="button" className="font-medium underline hover:no-underline">
{t("environments.unify.view_setup_guide")}
</button>
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" className="gap-2">
<RefreshCwIcon className="h-4 w-4" />
{t("environments.unify.test_connection")}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,259 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { MappingUI } from "./mapping-ui";
interface EditConnectorModalProps {
connector: TConnectorWithMappings | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateConnector: (data: {
connectorId: string;
environmentId: string;
name: string;
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
onDeleteConnector: (connectorId: string) => Promise<void>;
surveys: TUnifySurvey[];
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
}
function getConnectorTypeLabelKey(type: TConnectorType): string {
switch (type) {
case "formbricks":
return "environments.unify.formbricks_surveys";
case "csv":
return "environments.unify.csv_import";
default:
return type;
}
}
export function EditConnectorModal({
connector,
open,
onOpenChange,
onUpdateConnector,
onDeleteConnector,
surveys,
}: EditConnectorModalProps) {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
useEffect(() => {
if (connector) {
setConnectorName(connector.name);
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setSelectedElementIds(fbMappings.map((m) => m.elementId));
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
const columnsFromMappings = [
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
];
setSourceFields(
columnsFromMappings.length > 0
? parseCSVColumnsToFields(columnsFromMappings.join(","))
: parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS)
);
setMappings(
connector.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId,
targetFieldId: m.targetFieldId,
staticValue: m.staticValue ?? undefined,
}))
);
setSelectedSurveyId(null);
setSelectedElementIds([]);
} else {
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setSelectedElementIds([]);
}
}
}, [connector]);
const resetForm = () => {
setConnectorName("");
setMappings([]);
setSourceFields([]);
setShowDeleteConfirm(false);
setSelectedSurveyId(null);
setSelectedElementIds([]);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(survey.elements.map((e) => e.id));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
};
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
await onUpdateConnector({
connectorId: connector.id,
environmentId: connector.environmentId,
name: connectorName.trim(),
surveyId: connector.type === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
elementIds: connector.type === "formbricks" ? selectedElementIds : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
handleOpenChange(false);
};
const handleDelete = async () => {
if (!connector) return;
await onDeleteConnector(connector.id);
handleOpenChange(false);
};
if (!connector) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("environments.unify.edit_source_connection")}</DialogTitle>
<DialogDescription>{t("environments.unify.update_mapping_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type)}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("environments.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("environments.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
{connector.type === "formbricks" ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
) : (
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<div>
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">{t("environments.unify.are_you_sure")}</span>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t("environments.unify.yes_delete")}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
{t("environments.unify.delete_source")}
</Button>
)}
</div>
<Button
onClick={handleUpdate}
disabled={
!connectorName.trim() ||
(connector.type === "formbricks" && (!selectedSurveyId || selectedElementIds.length === 0))
}>
{t("environments.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,222 @@
"use client";
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleIcon,
FileTextIcon,
MessageSquareTextIcon,
StarIcon,
} from "lucide-react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Badge } from "@/modules/ui/components/badge";
import { TUnifySurvey } from "../types";
interface FormbricksSurveySelectorProps {
surveys: TUnifySurvey[];
selectedSurveyId: string | null;
selectedElementIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onElementToggle: (elementId: string) => void;
onSelectAllElements: (surveyId: string) => void;
onDeselectAllElements: () => void;
}
function getElementIcon(type: string) {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
}
export function FormbricksSurveySelector({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) {
const { t } = useTranslation();
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId === survey.id) {
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
} else {
onSurveySelect(survey.id);
onDeselectAllElements();
setExpandedSurveyId(survey.id);
}
};
const allElementsSelected = selectedSurvey && selectedElementIds.length === selectedSurvey.elements.length;
const getStatusBadge = (status: TUnifySurvey["status"]) => {
switch (status) {
case "active":
return <Badge text={t("environments.unify.status_active")} type="success" size="tiny" />;
case "paused":
return <Badge text={t("environments.unify.status_paused")} type="warning" size="tiny" />;
case "draft":
return <Badge text={t("environments.unify.status_draft")} type="gray" size="tiny" />;
case "completed":
return <Badge text={t("environments.unify.status_completed")} type="gray" size="tiny" />;
default:
return null;
}
};
return (
<div className="grid h-[50vh] grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="flex flex-col gap-3 overflow-hidden">
<h4 className="shrink-0 text-sm font-medium text-slate-700">
{t("environments.unify.select_survey")}
</h4>
<div className="space-y-2 overflow-y-auto pr-1">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.no_surveys_found")}</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
const isExpanded = expandedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-brand-dark bg-slate-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
) : (
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
{getStatusBadge(survey.status)}
</div>
<p className="text-xs text-slate-500">
{t("environments.unify.n_elements", { count: survey.elements.length })}
</p>
</div>
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* Right: Element Selection */}
<div className="flex flex-col gap-3 overflow-hidden">
<div className="flex shrink-0 items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_elements")}</h4>
{selectedSurvey && (
<button
type="button"
onClick={() =>
allElementsSelected ? onDeselectAllElements() : onSelectAllElements(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allElementsSelected
? t("environments.unify.deselect_all")
: t("environments.unify.select_all")}
</button>
)}
</div>
{!selectedSurvey ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{t("environments.unify.select_a_survey_to_see_elements")}
</p>
</div>
) : selectedSurvey.elements.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_elements")}</p>
</div>
) : (
<div className="space-y-2 overflow-y-auto pr-1">
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
return (
<button
key={element.id}
type="button"
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-green-300 bg-green-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
}`}>
{isSelected && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className="text-sm text-slate-900">{element.headline}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
{element.required && (
<span className="text-xs text-red-500">
<CircleIcon className="inline h-1.5 w-1.5 fill-current" />{" "}
{t("environments.unify.required")}
</span>
)}
</div>
</div>
</button>
);
})}
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<Trans
i18nKey={
selectedElementIds.length === 1
? "environments.unify.element_selected"
: "environments.unify.elements_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,309 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TFieldMapping, TSourceField, TTargetField } from "../types";
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isDragging
? "border-brand-dark bg-slate-100 opacity-50"
: isMapped
? "border-green-300 bg-green-50 text-green-800"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
}
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export function DroppableTargetField({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) {
const { t } = useTranslation();
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
// Handle enum field type - show dropdown
if (field.type === "enum" && field.enumValues) {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">{t("environments.unify.enum")}</span>
</div>
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("environments.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// Handle string fields - allow drag & drop OR static value
if (field.type === "string") {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= &ldquo;{mapping.staticValue}&rdquo;
</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show input for entering static value when editing */}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues
? `e.g., ${field.exampleStaticValues[0]}`
: t("environments.unify.enter_value")
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{/* Show example values as quick select OR drop zone */}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_field_or")}</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
{t("environments.unify.set_value")}
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("environments.unify.feedback_date");
return value;
};
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasDefaultMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show drop zone with preset options */}
{!hasDefaultMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("environments.unify.drop_a_field_here")}</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
interface MappingUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
connectorType: TConnectorType;
}
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const sourceFieldId = active.id as string;
const targetFieldId = over.id as string;
const newMappings = mappings.filter(
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
};
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-2 gap-6">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{connectorType === "csv"
? t("environments.unify.csv_columns")
: t("environments.unify.source_fields")}
</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{connectorType === "csv"
? t("environments.unify.click_load_sample_csv")
: t("environments.unify.no_source_fields_loaded")}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("environments.unify.hub_feedback_record_fields")}
</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.required")}
</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("environments.unify.optional")}
</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
@@ -0,0 +1,243 @@
import { describe, expect, test, vi } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { transformToUnifySurvey } from "./lib";
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: (str: string) => str,
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (val: Record<string, string>, _lang: string) => val?.default ?? "",
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
blocks.flatMap((block) => block.elements),
}));
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: (headline: Record<string, string>) => headline,
}));
const NOW = new Date("2026-02-24T10:00:00.000Z");
const createMockSurvey = (overrides: Partial<TSurvey> = {}): TSurvey =>
({
id: "survey-1",
name: "Test Survey",
status: "inProgress",
createdAt: NOW,
blocks: [
{
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: true,
},
{
id: "el-nps",
type: TSurveyElementTypeEnum.NPS,
headline: { default: "How likely to recommend?" },
required: false,
},
],
},
],
...overrides,
}) as unknown as TSurvey;
describe("transformToUnifySurvey", () => {
test("transforms a survey with basic elements", () => {
const result = transformToUnifySurvey(createMockSurvey());
expect(result).toEqual({
id: "survey-1",
name: "Test Survey",
status: "active",
createdAt: NOW,
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: "What do you think?",
required: true,
},
{
id: "el-nps",
type: TSurveyElementTypeEnum.NPS,
headline: "How likely to recommend?",
required: false,
},
],
});
});
test("filters out CTA elements", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-text",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Feedback" },
required: true,
},
{
id: "el-cta",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Click here" },
required: false,
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toBe("el-text");
});
test("defaults required to false when not set", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate us" },
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements[0].required).toBe(false);
});
test("falls back to 'Untitled' when headline is empty", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "" },
required: false,
},
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements[0].headline).toBe("Untitled");
});
describe("mapSurveyStatus", () => {
test("maps 'inProgress' to 'active'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial<TSurvey>));
expect(result.status).toBe("active");
});
test("maps 'paused' to 'paused'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
expect(result.status).toBe("paused");
});
test("maps 'draft' to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
expect(result.status).toBe("draft");
});
test("maps 'completed' to 'completed'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
expect(result.status).toBe("completed");
});
test("maps unknown status to 'draft'", () => {
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
expect(result.status).toBe("draft");
});
});
test("handles multiple blocks", () => {
const survey = createMockSurvey({
blocks: [
{
elements: [
{
id: "el-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
},
],
},
{
elements: [
{ id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false },
],
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toHaveLength(2);
expect(result.elements[0].id).toBe("el-1");
expect(result.elements[1].id).toBe("el-2");
});
test("handles empty blocks", () => {
const survey = createMockSurvey({ blocks: [] } as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
expect(result.elements).toEqual([]);
});
test("preserves all element types except CTA", () => {
const elementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.Date,
TSurveyElementTypeEnum.Consent,
TSurveyElementTypeEnum.Matrix,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.ContactInfo,
TSurveyElementTypeEnum.Address,
TSurveyElementTypeEnum.FileUpload,
TSurveyElementTypeEnum.Cal,
TSurveyElementTypeEnum.CTA,
];
const survey = createMockSurvey({
blocks: [
{
elements: elementTypes.map((type, i) => ({
id: `el-${i.toString()}`,
type,
headline: { default: `Question ${i.toString()}` },
required: false,
})),
},
],
} as Partial<TSurvey>);
const result = transformToUnifySurvey(survey);
const resultTypes = result.elements.map((e) => e.type);
expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA);
expect(result.elements).toHaveLength(elementTypes.length - 1);
});
});
@@ -0,0 +1,51 @@
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { TUnifySurvey, TUnifySurveyElement } from "./types";
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
return (
getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
) || "Untitled"
);
};
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
switch (status) {
case "inProgress":
return "active";
case "paused":
return "paused";
case "draft":
return "draft";
case "completed":
return "completed";
default:
return "draft";
}
};
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
const elements = getElementsFromBlocks(survey.blocks);
const unifySurveyElements: TUnifySurveyElement[] = elements
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
.map((el) => ({
id: el.id,
type: el.type,
headline: getElementHeadline(el, survey),
required: el.required ?? false,
}));
return {
id: survey.id,
name: survey.name,
status: mapSurveyStatus(survey.status),
elements: unifySurveyElements,
createdAt: survey.createdAt,
};
};
@@ -0,0 +1,26 @@
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
import { transformToUnifySurvey } from "./lib";
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
const [connectors, surveys] = await Promise.all([
getConnectorsWithMappings(params.environmentId),
getSurveys(params.environmentId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
environmentId={params.environmentId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
/>
);
}
@@ -0,0 +1,215 @@
import { TFunction } from "i18next";
import { z } from "zod";
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
export interface TUnifySurveyElement {
id: string;
type: TSurveyElementTypeEnum;
headline: string;
required: boolean;
}
export interface TUnifySurvey {
id: string;
name: string;
status: "draft" | "active" | "paused" | "completed";
elements: TUnifySurveyElement[];
createdAt: Date;
}
export interface TFieldMapping {
targetFieldId: string;
sourceFieldId?: string;
staticValue?: string;
}
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
export interface TTargetField {
id: string;
name: string;
type: TTargetFieldType;
required: boolean;
description: string;
enumValues?: THubFieldType[];
exampleStaticValues?: string[];
}
export interface TSourceField {
id: string;
name: string;
type: string;
sampleValue?: string;
}
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
{
id: "collected_at",
name: "Collected At",
type: "timestamp",
required: true,
description: "When the feedback was originally collected",
exampleStaticValues: ["$now"],
},
{
id: "source_type",
name: "Source Type",
type: "string",
required: true,
description: "Type of source (e.g., survey, review, support)",
exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"],
},
{
id: "field_id",
name: "Field ID",
type: "string",
required: true,
description: "Unique question/field identifier",
},
{
id: "field_type",
name: "Field Type",
type: "enum",
required: true,
description: "Data type (text, nps, csat, rating, etc.)",
enumValues: ZHubFieldType.options,
},
{
id: "tenant_id",
name: "Tenant ID",
type: "string",
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "source_id",
name: "Source ID",
type: "string",
required: false,
description: "Reference to survey/form/ticket/review ID",
},
{
id: "source_name",
name: "Source Name",
type: "string",
required: false,
description: "Human-readable source name for display",
exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"],
},
{
id: "field_label",
name: "Field Label",
type: "string",
required: false,
description: "Question text or field label for display",
},
{
id: "field_group_id",
name: "Field Group ID",
type: "string",
required: false,
description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)",
},
{
id: "field_group_label",
name: "Field Group Label",
type: "string",
required: false,
description: "Human-readable question text for the group",
},
{
id: "value_text",
name: "Value (Text)",
type: "string",
required: false,
description: "Text responses (feedback, comments, open-ended answers)",
},
{
id: "value_number",
name: "Value (Number)",
type: "float64",
required: false,
description: "Numeric responses (ratings, scores, NPS, CSAT)",
},
{
id: "value_boolean",
name: "Value (Boolean)",
type: "boolean",
required: false,
description: "Yes/no responses",
},
{
id: "value_date",
name: "Value (Date)",
type: "timestamp",
required: false,
description: "Date/datetime responses",
},
{
id: "metadata",
name: "Metadata",
type: "jsonb",
required: false,
description: "Flexible context (device, location, campaign, custom fields)",
},
{
id: "language",
name: "Language",
type: "string",
required: false,
description: "ISO 639-1 language code (e.g., en, de, fr)",
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
},
{
id: "user_identifier",
name: "User Identifier",
type: "string",
required: false,
description: "Anonymous user ID for tracking (hashed, never PII)",
},
];
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
export const MAX_CSV_VALUES = {
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
RECORDS: 1_000, // 1,000 records
} as const;
export const createFeedbackCSVDataSchema = (t: TFunction) =>
z
.array(z.record(z.string(), z.string()))
.min(1, { message: t("environments.unify.csv_at_least_one_row") })
.max(MAX_CSV_VALUES.RECORDS, {
message: t("environments.unify.csv_max_records", {
max: MAX_CSV_VALUES.RECORDS.toLocaleString(),
}),
})
.superRefine((rows, ctx) => {
const localeSort = (a: string, b: string) => a.localeCompare(b);
const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(",");
for (let i = 1; i < rows.length; i++) {
const rowKeys = Object.keys(rows[i]).sort(localeSort).join(",");
if (rowKeys !== firstRowKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("environments.unify.csv_inconsistent_columns", { row: (i + 1).toString() }),
});
return;
}
}
const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === "");
if (emptyHeaders.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("environments.unify.csv_empty_column_headers"),
});
}
});
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { TSourceField } from "./types";
import { getConnectorOptions, parseCSVColumnsToFields } from "./utils";
const mockT = (key: string) => key;
describe("getConnectorOptions", () => {
test("returns formbricks and csv options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(2);
expect(options[0].id).toBe("formbricks");
expect(options[1].id).toBe("csv");
});
test("both options are enabled by default", () => {
const options = getConnectorOptions(mockT as never);
expect(options.every((o) => !o.disabled)).toBe(true);
});
test("uses translation keys for name and description", () => {
const options = getConnectorOptions(mockT as never);
expect(options[0].name).toBe("environments.unify.formbricks_surveys");
expect(options[0].description).toBe("environments.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("environments.unify.csv_import");
expect(options[1].description).toBe("environments.unify.source_connect_csv_description");
});
});
describe("parseCSVColumnsToFields", () => {
test("parses comma-separated column names into source fields", () => {
const result = parseCSVColumnsToFields("name,email,score");
expect(result).toHaveLength(3);
expect(result).toEqual<TSourceField[]>([
{ id: "name", name: "name", type: "string", sampleValue: "Sample name" },
{ id: "email", name: "email", type: "string", sampleValue: "Sample email" },
{ id: "score", name: "score", type: "string", sampleValue: "Sample score" },
]);
});
test("trims whitespace from column names", () => {
const result = parseCSVColumnsToFields(" name , email , score ");
expect(result[0].id).toBe("name");
expect(result[1].id).toBe("email");
expect(result[2].id).toBe("score");
});
test("handles single column", () => {
const result = parseCSVColumnsToFields("feedback");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feedback");
});
test("generates sample values from column names", () => {
const result = parseCSVColumnsToFields("rating,comment");
expect(result[0].sampleValue).toBe("Sample rating");
expect(result[1].sampleValue).toBe("Sample comment");
});
});
@@ -0,0 +1,32 @@
import { TFunction } from "i18next";
import { TSourceField } from "./types";
export interface TConnectorOption {
id: string;
name: string;
description: string;
disabled: boolean;
badge?: { text: string; type: "success" | "gray" | "warning" };
}
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
{
id: "formbricks",
name: t("environments.unify.formbricks_surveys"),
description: t("environments.unify.source_connect_formbricks_description"),
disabled: false,
},
{
id: "csv",
name: t("environments.unify.csv_import"),
description: t("environments.unify.source_connect_csv_description"),
disabled: false,
},
];
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
return columns.split(",").map((col) => {
const trimmed = col.trim();
return { id: trimmed, name: trimmed, type: "string", sampleValue: `Sample ${trimmed}` };
});
};
@@ -8,6 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { CRON_SECRET } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
@@ -141,6 +142,14 @@ export const POST = async (request: Request) => {
});
if (event === "responseFinished") {
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
// This sends response data to the Hub for configured connectors
try {
await handleConnectorPipeline(response, survey, environmentId);
} catch (error) {
// Log but don't throw - connector failures shouldn't break the main pipeline
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
}
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
getIntegrations(environmentId),
+107 -1
View File
@@ -114,6 +114,7 @@ checksums:
common/app_survey: f076d131d20bfdadb35fba29c8275232
common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
common/ask: 24150ae04c60dcd8688d93a8a3a2d238
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
common/back: f541015a827e37cb3b1234e56bc2aa3c
common/billing: b01dbdd049ebbd4a349fa64d6ce65a3b
@@ -136,7 +137,7 @@ checksums:
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: 923ec0502721489202f6222dd4107163
common/configure: e3ab18ebb36c218cd4897c620f5809ac
common/confirm: 90930b51154032f119fa75c1bd422d8b
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
@@ -172,6 +173,7 @@ checksums:
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
common/discard: de83a114a79d086e372c43dbfe9f47b4
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
common/distribute: 0b702c85b5d4069d8367cb461c2ee0b1
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
@@ -240,6 +242,7 @@ checksums:
common/logout: 07948fdf20705e04a7bf68ab197512bf
common/look_and_feel: 9125503712626d495cedec7a79f1418c
common/manage: a3d40c0267b81ae53c9598eaeb05087d
common/mappings: 938751312ce179df491c94c1243546e7
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
@@ -401,6 +404,7 @@ checksums:
common/top_right: 241f95c923846911aaf13af6109333e5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unify: bdb518a1e62f51049ccc4366b909fb0a
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
@@ -1928,6 +1932,108 @@ checksums:
environments/surveys/templates/multiple_industries: 7dcb6f6d87feb08f8004dfb5a91e711f
environments/surveys/templates/use_this_template: 69020c8b5a521b8f027616bb5c4b64dd
environments/surveys/templates/uses_branching_logic: 7ac087d7067d342c17809d4ce497dfe0
environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
environments/unify/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
environments/unify/automated: 040d99fc1e8649d9bfac6e45759ff119
environments/unify/aws_region: 6d7132311a69d6288cee9dbfec27227a
environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
environments/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
environments/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
environments/unify/connection: 421e709602c92ffbe04a266f6a092089
environments/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
environments/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
environments/unify/copied: 0d1b21bf6919e363f5c4a4ac75dab210
environments/unify/copy: 627c00d2c850b9b45f7341a6ac01b6bb
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
environments/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
environments/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
environments/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/csv_inconsistent_columns: 4a1b331f61018fc6721ab7557be32210
environments/unify/csv_max_records: e0dda2a8f66feb4aa60aba327b8511d5
environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
environments/unify/delete_source: f1efd5e1c403192a063b761ddfeaf34a
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
environments/unify/drop_zone_path: 8e60cc5a0b7b74fe624cfdc0b11a884d
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
environments/unify/element_selected: f194010dff50242e6f123e0a7da2094c
environments/unify/elements_selected: 058a38789415da7fc08b976cdcc1ac66
environments/unify/enable_auto_sync: d23ed425a77525a905365631b068ab93
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
environments/unify/enter_value: 4f068bb59617975c1e546218373122cd
environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
environments/unify/every_15_minutes: 82b7ca02645256b843b92e3629429f02
environments/unify/every_30_minutes: 6bba217e921f55cad68948d6136d23c0
environments/unify/every_5_minutes: 4ecba56de234044216c3db522f542109
environments/unify/every_hour: 1314cadc59cef3d1f63f59c30f58fba1
environments/unify/feedback_date: 4ada116cc8375dd67483108eeb0ddfe8
environments/unify/field: 87d7b2d449e2231e5d75ff64015a8cf3
environments/unify/fields: 3b02117e12872bf0cd2b6056728216e8
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
environments/unify/hub_feedback_record_fields: d8e7b6bb8b7c45d8bd69e5f32193dde4
environments/unify/iam_configuration_required: 2da3c3c5fd9de01c815204c33e0baf58
environments/unify/iam_setup_instructions: f165c08df18347c0692a45b9fc846a6c
environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
environments/unify/n_elements: 4a0906410e783ec98f58367eb0ce0f8c
environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
environments/unify/optional: 396fb9a0472daf401c392bdc3e248943
environments/unify/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
environments/unify/process_new_files_description: a739c0cc86c92940fe1302ba1ad244e5
environments/unify/processing_interval: ff66d16920ad6815efeaabf6f61fc260
environments/unify/region_ap_southeast_1: 6932d3023a19c776499445f1f415394d
environments/unify/region_eu_central_1: 8476430efbe3f4edb9295d5c6e6d05f9
environments/unify/region_eu_west_1: f543a1457e57e68c0b19e01bf7351b8f
environments/unify/region_us_east_1: 606343effd2647363eda831cb1fcc494
environments/unify/region_us_west_2: 45165afea3626c112b9e850fb88c0d5d
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/unify/s3_bucket_description: 45ca98a3e925254b831969930ef00953
environments/unify/s3_bucket_integration: 9095ce49ee205bb39065928b527c37fa
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
environments/unify/select_a_survey_to_see_elements: e549e92e8e2fda4fc6cfc62661a4b328
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
environments/unify/select_elements: c336db5308ff54b1dd8b717fad7dbaff
environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
environments/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
environments/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
environments/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
environments/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
environments/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
environments/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
environments/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
environments/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61
environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
environments/unify/status_active: 3e1ec025c4a50830bbb9ad57a176630a
environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
environments/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
environments/unify/survey_has_no_elements: 0379106932976c0a61119a20992d4b18
environments/unify/test_connection: 6bddfcf3e2a1e806057514093a3fe071
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
environments/unify/upload_csv_data_description: 61ff18cadfd21ef9820a203bb035d616
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
environments/unify/view_setup_guide: 3edf6288a06af663cff24a74cbcba235
environments/unify/yes_delete: 7a260e784409a9112f77d213754cd3e0
environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
+229
View File
@@ -0,0 +1,229 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
TConnectorWithMappings,
ZConnectorCreateInput,
ZConnectorFieldMappingCreateInput,
ZConnectorUpdateInput,
getHubFieldTypeFromElementType,
} from "@formbricks/types/connector";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getOrganizationIdFromConnectorId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromSurveyId,
getProjectIdFromConnectorId,
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import {
TMappingsInput,
createConnectorWithMappings,
deleteConnector,
updateConnectorWithMappings,
} from "./service";
const ZDeleteConnectorAction = z.object({
connectorId: ZId,
environmentId: ZId,
});
export const deleteConnectorAction = authenticatedActionClient
.schema(ZDeleteConnectorAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteConnectorAction>;
}) => {
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
},
],
});
return deleteConnector(parsedInput.connectorId, parsedInput.environmentId);
}
);
const resolveFormbricksMappingsInput = async (
surveyId: string,
elementIds: string[]
): Promise<TMappingsInput> => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const mappings = elementIds
.filter((elementId) => {
if (elementMap.has(elementId)) return true;
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
return false;
})
.map((elementId) => {
const element = elementMap.get(elementId)!;
return {
surveyId,
elementId,
hubFieldType: getHubFieldTypeFromElementType(element.type),
};
});
return { type: "formbricks", mappings };
};
const ZCreateConnectorWithMappingsAction = z.object({
environmentId: ZId,
connectorInput: ZConnectorCreateInput,
formbricksMappings: z
.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
})
.optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
});
export const createConnectorWithMappingsAction = authenticatedActionClient
.schema(ZCreateConnectorWithMappingsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateConnectorWithMappingsAction>;
}): Promise<TConnectorWithMappings> => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
let mappingsInput: TMappingsInput | undefined;
const { formbricksMappings, fieldMappings } = parsedInput;
if (formbricksMappings) {
const organizationIdFromSurvey = await getOrganizationIdFromSurveyId(formbricksMappings.surveyId);
if (organizationIdFromSurvey !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
mappingsInput = await resolveFormbricksMappingsInput(
formbricksMappings.surveyId,
formbricksMappings.elementIds
);
} else if (fieldMappings?.length) {
mappingsInput = { type: "field", mappings: fieldMappings };
}
return createConnectorWithMappings(
parsedInput.environmentId,
parsedInput.connectorInput,
mappingsInput
);
}
);
const ZUpdateConnectorWithMappingsAction = z.object({
connectorId: ZId,
environmentId: ZId,
connectorInput: ZConnectorUpdateInput,
formbricksMappings: z
.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
})
.optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
});
export const updateConnectorWithMappingsAction = authenticatedActionClient
.schema(ZUpdateConnectorWithMappingsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateConnectorWithMappingsAction>;
}): Promise<TConnectorWithMappings> => {
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
},
],
});
let mappingsInput: TMappingsInput | undefined;
if (parsedInput.formbricksMappings) {
const organizationIdFromSurvey = await getOrganizationIdFromSurveyId(
parsedInput.formbricksMappings.surveyId
);
if (organizationIdFromSurvey !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
mappingsInput = await resolveFormbricksMappingsInput(
parsedInput.formbricksMappings.surveyId,
parsedInput.formbricksMappings.elementIds
);
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
}
return updateConnectorWithMappings(
parsedInput.connectorId,
parsedInput.environmentId,
parsedInput.connectorInput,
mappingsInput
);
}
);
+317
View File
@@ -0,0 +1,317 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { HUB_API_KEY, HUB_API_URL } from "@/lib/constants";
// Hub field types (from OpenAPI spec)
export type THubFieldType =
| "text"
| "categorical"
| "nps"
| "csat"
| "ces"
| "rating"
| "number"
| "boolean"
| "date";
// Create FeedbackRecord input
export interface TCreateFeedbackRecordInput {
collected_at?: string;
source_type: string;
field_id: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
source_id?: string;
source_name?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// FeedbackRecord data (response from Hub)
export interface TFeedbackRecordData {
id: string;
collected_at: string;
created_at: string;
updated_at: string;
source_type: string;
field_id: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
source_id?: string;
source_name?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// List FeedbackRecords response
export interface TListFeedbackRecordsResponse {
data: TFeedbackRecordData[];
total: number;
limit: number;
offset: number;
}
// Update FeedbackRecord input
export interface TUpdateFeedbackRecordInput {
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// List FeedbackRecords filters
export interface TListFeedbackRecordsFilters {
tenant_id?: string;
source_type?: string;
source_id?: string;
field_id?: string;
field_group_id?: string;
field_type?: THubFieldType;
user_identifier?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
}
// Error response from Hub
export interface THubErrorResponse {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
errors?: Array<{
location?: string;
message?: string;
value?: unknown;
}>;
}
// Hub API Error class
export class HubApiError extends Error {
status: number;
detail: string;
errors?: THubErrorResponse["errors"];
constructor(response: THubErrorResponse) {
super(response.detail || response.title);
this.name = "HubApiError";
this.status = response.status;
this.detail = response.detail;
this.errors = response.errors;
}
}
// Make authenticated request to Hub API
async function hubFetch<T>(
path: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: HubApiError | null }> {
const url = `${HUB_API_URL}${path}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(HUB_API_KEY && { Authorization: `Bearer ${HUB_API_KEY}` }),
...options.headers,
};
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle no content response (e.g., DELETE)
if (response.status === 204) {
return { data: null, error: null };
}
const contentType = response.headers.get("content-type");
if (!response.ok) {
// Try to parse error response
if (contentType?.includes("application/problem+json") || contentType?.includes("application/json")) {
const errorBody = (await response.json()) as THubErrorResponse;
return { data: null, error: new HubApiError(errorBody) };
}
// Fallback for non-JSON errors
const errorText = await response.text();
return {
data: null,
error: new HubApiError({
title: "Error",
status: response.status,
detail: errorText || `HTTP ${response.status}`,
}),
};
}
// Parse successful response
if (contentType?.includes("application/json")) {
const data = (await response.json()) as T;
return { data, error: null };
}
return { data: null, error: null };
} catch (error) {
logger.error(
{ url, error: error instanceof Error ? error.message : "Unknown error" },
"Hub API request failed"
);
return {
data: null,
error: new HubApiError({
title: "Network Error",
status: 0,
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
}),
};
}
}
/**
* Create a new FeedbackRecord in the Hub
*/
export async function createFeedbackRecord(
input: TCreateFeedbackRecordInput
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>("/v1/feedback-records", {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* Create multiple FeedbackRecords in the Hub (batch)
*/
export async function createFeedbackRecordsBatch(
inputs: TCreateFeedbackRecordInput[]
): Promise<{ results: Array<{ data: TFeedbackRecordData | null; error: HubApiError | null }> }> {
// Hub doesn't have a batch endpoint, so we'll do parallel requests
// In production, you might want to implement rate limiting or chunking
const results = await Promise.all(inputs.map((input) => createFeedbackRecord(input)));
return { results };
}
/**
* List FeedbackRecords from the Hub with optional filters
*/
export async function listFeedbackRecords(
filters: TListFeedbackRecordsFilters = {}
): Promise<{ data: TListFeedbackRecordsResponse | null; error: HubApiError | null }> {
const searchParams = new URLSearchParams();
if (filters.tenant_id) searchParams.set("tenant_id", filters.tenant_id);
if (filters.source_type) searchParams.set("source_type", filters.source_type);
if (filters.source_id) searchParams.set("source_id", filters.source_id);
if (filters.field_id) searchParams.set("field_id", filters.field_id);
if (filters.field_group_id) searchParams.set("field_group_id", filters.field_group_id);
if (filters.field_type) searchParams.set("field_type", filters.field_type);
if (filters.user_identifier) searchParams.set("user_identifier", filters.user_identifier);
if (filters.since) searchParams.set("since", filters.since);
if (filters.until) searchParams.set("until", filters.until);
if (filters.limit !== undefined) searchParams.set("limit", String(filters.limit));
if (filters.offset !== undefined) searchParams.set("offset", String(filters.offset));
const queryString = searchParams.toString();
const path = queryString ? `/v1/feedback-records?${queryString}` : "/v1/feedback-records";
return hubFetch<TListFeedbackRecordsResponse>(path, { method: "GET" });
}
/**
* Get a single FeedbackRecord from the Hub by ID
*/
export async function getFeedbackRecord(
id: string
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, { method: "GET" });
}
/**
* Update a FeedbackRecord in the Hub
*/
export async function updateFeedbackRecord(
id: string,
input: TUpdateFeedbackRecordInput
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
/**
* Delete a FeedbackRecord from the Hub
*/
export async function deleteFeedbackRecord(id: string): Promise<{ error: HubApiError | null }> {
const result = await hubFetch<null>(`/v1/feedback-records/${id}`, { method: "DELETE" });
return { error: result.error };
}
/**
* Bulk delete FeedbackRecords by user identifier (GDPR compliance)
*/
export async function bulkDeleteFeedbackRecordsByUser(
userIdentifier: string,
tenantId?: string
): Promise<{ data: { deleted_count: number; message: string } | null; error: HubApiError | null }> {
const searchParams = new URLSearchParams();
searchParams.set("user_identifier", userIdentifier);
if (tenantId) searchParams.set("tenant_id", tenantId);
return hubFetch<{ deleted_count: number; message: string }>(
`/v1/feedback-records?${searchParams.toString()}`,
{ method: "DELETE" }
);
}
/**
* Check Hub API health
*/
export async function checkHubHealth(): Promise<{ healthy: boolean; error: HubApiError | null }> {
try {
const response = await fetch(`${HUB_API_URL}/health`);
if (response.ok) {
return { healthy: true, error: null };
}
return {
healthy: false,
error: new HubApiError({
title: "Health Check Failed",
status: response.status,
detail: "Hub API health check failed",
}),
};
} catch (error) {
return {
healthy: false,
error: new HubApiError({
title: "Network Error",
status: 0,
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
}),
};
}
}
+144
View File
@@ -0,0 +1,144 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "./hub-client";
import { getConnectorsBySurveyId, updateConnector } from "./service";
import { transformResponseToFeedbackRecords } from "./transform";
/**
* Handle connector pipeline for a survey response
*
* This function is called from the pipeline when a response is created/finished.
* It looks up active connectors for the survey and sends the response data to the Hub.
*
* @param response - The survey response
* @param survey - The survey
* @param environmentId - The environment ID (used as tenant_id)
*/
export const handleConnectorPipeline = async (
response: TResponse,
survey: TSurvey,
environmentId: string
): Promise<void> => {
try {
// Get all active Formbricks connectors for this survey
const connectors = await getConnectorsBySurveyId(survey.id);
if (connectors.length === 0) {
// No connectors configured for this survey
return;
}
// Process each connector
for (const connector of connectors) {
try {
// Transform response to FeedbackRecords using the connector's mappings
const feedbackRecords = transformResponseToFeedbackRecords(
response,
survey,
connector.formbricksMappings,
environmentId // Use environment ID as tenant_id
);
if (feedbackRecords.length === 0) {
// No mapped elements had values in this response
continue;
}
// Send to Hub API
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
// Count successes and failures
const successes = results.filter((r) => r.data !== null).length;
const failures = results.filter((r) => r.error !== null).length;
if (failures > 0) {
logger.warn(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
successes,
failures,
},
`Connector pipeline: ${failures}/${feedbackRecords.length} FeedbackRecords failed to send`
);
// Log the specific errors
results.forEach((result, index) => {
if (result.error) {
logger.error(
{
connectorId: connector.id,
feedbackRecordIndex: index,
error: {
status: result.error.status,
message: result.error.message,
detail: result.error.detail,
},
},
"Failed to create FeedbackRecord in Hub"
);
}
});
if (successes === 0) {
await updateConnector(connector.id, environmentId, {
status: "error",
errorMessage: `Failed to send FeedbackRecords to Hub: ${results[0].error?.message || "Unknown error"}`,
});
} else {
await updateConnector(connector.id, environmentId, {
status: "active",
errorMessage: `Partial failure: ${successes}/${feedbackRecords.length} records sent`,
lastSyncAt: new Date(),
});
}
} else {
logger.info(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
feedbackRecordsCreated: successes,
},
`Connector pipeline: Successfully sent ${successes} FeedbackRecords to Hub`
);
await updateConnector(connector.id, environmentId, {
status: "active",
errorMessage: null,
lastSyncAt: new Date(),
});
}
} catch (error) {
logger.error(
{
connectorId: connector.id,
surveyId: survey.id,
responseId: response.id,
error: error instanceof Error ? error.message : "Unknown error",
},
"Connector pipeline: Failed to process connector"
);
// Update connector with error
await updateConnector(connector.id, environmentId, {
status: "error",
errorMessage: error instanceof Error ? error.message : "Unknown error",
});
}
}
} catch (error) {
// Log but don't throw - we don't want to break the main pipeline
logger.error(
{
surveyId: survey.id,
responseId: response.id,
error: error instanceof Error ? error.message : "Unknown error",
},
"Connector pipeline: Failed to handle connectors"
);
}
};
+519
View File
@@ -0,0 +1,519 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
createConnectorWithMappings,
deleteConnector,
getConnectorsBySurveyId,
getConnectorsWithMappings,
updateConnector,
updateConnectorWithMappings,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
connector: {
findMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockConnector = {
id: CONNECTOR_ID,
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks" as const,
status: "active" as const,
environmentId: ENV_ID,
lastSyncAt: null,
errorMessage: null,
};
const mockConnectorWithMappings = {
...mockConnector,
formbricksMappings: [
{
id: "mapping-1",
createdAt: NOW,
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
customFieldLabel: null,
},
],
fieldMappings: [],
};
describe("getConnectorsWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns connectors for the given environment", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappings] as never);
const result = await getConnectorsWithMappings(ENV_ID);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { environmentId: ENV_ID },
orderBy: { createdAt: "desc" },
})
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(CONNECTOR_ID);
});
test("applies pagination when page is provided", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
await getConnectorsWithMappings(ENV_ID, 2);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: expect.any(Number),
skip: expect.any(Number),
})
);
});
test("returns empty array when no connectors exist", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
const result = await getConnectorsWithMappings(ENV_ID);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.connector.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("connection error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(getConnectorsWithMappings(ENV_ID)).rejects.toThrow(DatabaseError);
});
});
describe("getConnectorsBySurveyId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns active formbricks connectors linked to the survey", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappings] as never);
const result = await getConnectorsBySurveyId(SURVEY_ID);
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: "formbricks",
status: "active",
formbricksMappings: { some: { surveyId: SURVEY_ID } },
},
})
);
expect(result).toHaveLength(1);
});
test("returns empty when no connectors match", async () => {
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
const result = await getConnectorsBySurveyId(SURVEY_ID);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.connector.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(getConnectorsBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError);
});
});
describe("updateConnector", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates connector name and returns the result", async () => {
const updated = { ...mockConnector, name: "Renamed" };
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { name: "Renamed" });
expect(prisma.connector.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
data: expect.objectContaining({ name: "Renamed" }),
})
);
expect(result.name).toBe("Renamed");
});
test("updates connector status", async () => {
const updated = { ...mockConnector, status: "paused" };
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { status: "paused" });
expect(result.status).toBe("paused");
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(DatabaseError);
});
test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.connector.update).mockRejectedValue(new Error("unexpected"));
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow("unexpected");
});
});
describe("deleteConnector", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes the connector and returns it", async () => {
vi.mocked(prisma.connector.delete).mockResolvedValue(mockConnector as never);
const result = await deleteConnector(CONNECTOR_ID, ENV_ID);
expect(prisma.connector.delete).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
})
);
expect(result.id).toBe(CONNECTOR_ID);
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.connector.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.connector.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(DatabaseError);
});
});
describe("createConnectorWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const setupTransaction = () => {
const txMethods = {
connector: {
create: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
},
};
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
});
return txMethods;
};
test("creates connector without mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings);
const result = await createConnectorWithMappings(ENV_ID, { name: "New", type: "formbricks" });
expect(tx.connector.create).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "New", type: "formbricks", environmentId: ENV_ID },
})
);
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
expect(tx.connectorFieldMapping.create).not.toHaveBeenCalled();
expect(result).toEqual(mockConnectorWithMappings);
});
test("creates connector with formbricks mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connectorFormbricksMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings);
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks" },
{
type: "formbricks",
mappings: [
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
],
}
);
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(2);
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
surveyId: SURVEY_ID,
elementId: "el-1",
hubFieldType: "text",
}),
})
);
});
test("creates connector with field mappings", async () => {
const tx = setupTransaction();
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, environmentId: ENV_ID });
tx.connectorFieldMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue({
...mockConnector,
formbricksMappings: [],
fieldMappings: [],
});
await createConnectorWithMappings(
ENV_ID,
{ name: "CSV", type: "csv" },
{
type: "field",
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
}
);
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
expect(tx.connectorFieldMapping.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
connectorId: CONNECTOR_ID,
environmentId: ENV_ID,
sourceFieldId: "col-1",
targetFieldId: "value_text",
}),
})
);
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
})
);
await expect(createConnectorWithMappings(ENV_ID, { name: "Dup", type: "formbricks" })).rejects.toThrow(
InvalidInputError
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow(
DatabaseError
);
});
});
describe("updateConnectorWithMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const setupTransaction = () => {
const txMethods = {
connector: {
update: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
connectorFormbricksMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
connectorFieldMapping: {
create: vi.fn(),
deleteMany: vi.fn(),
},
};
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
});
return txMethods;
};
test("updates connector name without changing mappings", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings);
const result = await updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Updated" });
expect(tx.connector.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTOR_ID, environmentId: ENV_ID },
data: expect.objectContaining({ name: "Updated" }),
})
);
expect(tx.connectorFormbricksMapping.deleteMany).not.toHaveBeenCalled();
expect(tx.connectorFieldMapping.deleteMany).not.toHaveBeenCalled();
expect(result).toEqual(mockConnectorWithMappings);
});
test("replaces formbricks mappings when provided", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connectorFormbricksMapping.deleteMany.mockResolvedValue({ count: 1 });
tx.connectorFormbricksMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappings);
await updateConnectorWithMappings(
CONNECTOR_ID,
ENV_ID,
{ name: "Updated" },
{
type: "formbricks",
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
}
);
expect(tx.connectorFormbricksMapping.deleteMany).toHaveBeenCalledWith({
where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID },
});
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(1);
});
test("replaces field mappings when provided", async () => {
const tx = setupTransaction();
tx.connector.update.mockResolvedValue(undefined);
tx.connectorFieldMapping.deleteMany.mockResolvedValue({ count: 1 });
tx.connectorFieldMapping.create.mockResolvedValue({});
tx.connector.findUniqueOrThrow.mockResolvedValue({
...mockConnector,
formbricksMappings: [],
fieldMappings: [],
});
await updateConnectorWithMappings(
CONNECTOR_ID,
ENV_ID,
{ name: "CSV Updated" },
{
type: "field",
mappings: [{ sourceFieldId: "col-x", targetFieldId: "value_number" }],
}
);
expect(tx.connectorFieldMapping.deleteMany).toHaveBeenCalledWith({
where: { connectorId: CONNECTOR_ID, environmentId: ENV_ID },
});
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
});
test("throws ResourceNotFoundError when connector does not exist", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
clientVersion: "5.0.0",
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
ResourceNotFoundError
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
DatabaseError
);
});
});
+336
View File
@@ -0,0 +1,336 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import {
TConnector,
TConnectorCreateInput,
TConnectorFieldMappingCreateInput,
TConnectorFormbricksMappingCreateInput,
TConnectorUpdateInput,
TConnectorWithMappings,
ZConnectorCreateInput,
ZConnectorUpdateInput,
} from "@formbricks/types/connector";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
const selectConnectorWithMappings = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
lastSyncAt: true,
errorMessage: true,
formbricksMappings: {
select: {
id: true,
createdAt: true,
connectorId: true,
environmentId: true,
surveyId: true,
elementId: true,
hubFieldType: true,
customFieldLabel: true,
},
},
fieldMappings: {
select: {
id: true,
createdAt: true,
connectorId: true,
environmentId: true,
sourceFieldId: true,
targetFieldId: true,
staticValue: true,
},
},
} satisfies Prisma.ConnectorSelect;
const selectConnector = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
lastSyncAt: true,
errorMessage: true,
} satisfies Prisma.ConnectorSelect;
export const getConnectorsWithMappings = reactCache(
async (environmentId: string, page?: number): Promise<TConnectorWithMappings[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const connectors = await prisma.connector.findMany({
where: {
environmentId,
},
select: selectConnectorWithMappings,
orderBy: {
createdAt: "desc",
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return connectors as TConnectorWithMappings[];
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getConnectorsBySurveyId = reactCache(
async (surveyId: string): Promise<TConnectorWithMappings[]> => {
validateInputs([surveyId, ZId]);
try {
const connectors = await prisma.connector.findMany({
where: {
type: "formbricks",
status: "active",
formbricksMappings: {
some: {
surveyId,
},
},
},
select: selectConnectorWithMappings,
});
return connectors as TConnectorWithMappings[];
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const updateConnector = async (
connectorId: string,
environmentId: string,
data: TConnectorUpdateInput
): Promise<TConnector> => {
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]);
try {
const connector = await prisma.connector.update({
where: {
id: connectorId,
environmentId,
},
data: {
name: data.name,
status: data.status,
errorMessage: data.errorMessage,
lastSyncAt: data.lastSyncAt,
},
select: selectConnector,
});
return connector as TConnector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteConnector = async (connectorId: string, environmentId: string): Promise<TConnector> => {
validateInputs([connectorId, ZId], [environmentId, ZId]);
try {
const connector = await prisma.connector.delete({
where: {
id: connectorId,
environmentId,
},
select: selectConnector,
});
return connector as TConnector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
// -- Composite functions --
export type TFormbricksMappingsInput = {
type: "formbricks";
mappings: TConnectorFormbricksMappingCreateInput[];
};
export type TFieldMappingsInput = {
type: "field";
mappings: TConnectorFieldMappingCreateInput[];
};
export type TMappingsInput = TFormbricksMappingsInput | TFieldMappingsInput;
export const createConnectorWithMappings = async (
environmentId: string,
data: TConnectorCreateInput,
mappingsInput?: TMappingsInput
): Promise<TConnectorWithMappings> => {
validateInputs([environmentId, ZId], [data, ZConnectorCreateInput]);
try {
const result = await prisma.$transaction(async (tx) => {
const connector = await tx.connector.create({
data: {
name: data.name,
type: data.type,
environmentId,
},
});
if (mappingsInput?.type === "formbricks") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
data: {
connectorId: connector.id,
environmentId,
surveyId: mapping.surveyId,
elementId: mapping.elementId,
hubFieldType: mapping.hubFieldType,
customFieldLabel: mapping.customFieldLabel,
},
})
)
);
} else if (mappingsInput?.type === "field") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFieldMapping.create({
data: {
connectorId: connector.id,
environmentId,
sourceFieldId: mapping.sourceFieldId,
targetFieldId: mapping.targetFieldId,
staticValue: mapping.staticValue,
},
})
)
);
}
return tx.connector.findUniqueOrThrow({
where: { id: connector.id },
select: selectConnectorWithMappings,
});
});
return result as TConnectorWithMappings;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateConnectorWithMappings = async (
connectorId: string,
environmentId: string,
data: TConnectorUpdateInput,
mappingsInput?: TMappingsInput
): Promise<TConnectorWithMappings> => {
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [environmentId, ZId]);
try {
const result = await prisma.$transaction(async (tx) => {
await tx.connector.update({
where: { id: connectorId, environmentId },
data: {
name: data.name,
status: data.status,
errorMessage: data.errorMessage,
lastSyncAt: data.lastSyncAt,
},
});
if (mappingsInput?.type === "formbricks") {
await tx.connectorFormbricksMapping.deleteMany({
where: { connectorId, environmentId },
});
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
data: {
connectorId,
environmentId,
surveyId: mapping.surveyId,
elementId: mapping.elementId,
hubFieldType: mapping.hubFieldType,
customFieldLabel: mapping.customFieldLabel,
},
})
)
);
} else if (mappingsInput?.type === "field") {
await tx.connectorFieldMapping.deleteMany({
where: { connectorId, environmentId },
});
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFieldMapping.create({
data: {
connectorId,
environmentId,
sourceFieldId: mapping.sourceFieldId,
targetFieldId: mapping.targetFieldId,
staticValue: mapping.staticValue,
},
})
)
);
}
return tx.connector.findUniqueOrThrow({
where: { id: connectorId },
select: selectConnectorWithMappings,
});
});
return result as TConnectorWithMappings;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
throw new DatabaseError(error.message);
}
throw error;
}
};
+316
View File
@@ -0,0 +1,316 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorFormbricksMapping } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { transformResponseToFeedbackRecords } from "./transform";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (_val: Record<string, string>, _lang: string) => _val?.default ?? "",
}));
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: (str: string) => str,
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
blocks.flatMap((block) => block.elements),
}));
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockSurvey = {
id: "survey-1",
name: "Product Feedback",
blocks: [
{
elements: [
{ id: "el-text", type: "openText", headline: { default: "How can we improve?" } },
{ id: "el-nps", type: "nps", headline: { default: "How likely to recommend?" } },
{ id: "el-rating", type: "rating", headline: { default: "Rate your experience" } },
{ id: "el-date", type: "date", headline: { default: "When did you visit?" } },
{ id: "el-bool", type: "consent", headline: { default: "Do you agree?" } },
{
id: "el-multi",
type: "multipleChoiceMulti",
headline: { default: "Select features" },
},
],
},
],
} as unknown as TSurvey;
const mockResponse = {
id: "resp-1",
createdAt: NOW,
data: {
"el-text": "Great product!",
"el-nps": 9,
"el-rating": 4,
"el-date": "2026-01-15",
"el-bool": "true",
"el-multi": ["feat-a", "feat-b"],
},
language: "en",
contact: { userId: "user-42" },
} as unknown as TResponse;
const createMapping = (
overrides: Partial<TConnectorFormbricksMapping> &
Pick<TConnectorFormbricksMapping, "elementId" | "hubFieldType">
): TConnectorFormbricksMapping => ({
id: `mapping-${overrides.elementId}`,
createdAt: NOW,
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
customFieldLabel: null,
...overrides,
});
const allMappings: TConnectorFormbricksMapping[] = [
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
createMapping({ elementId: "el-rating", hubFieldType: "rating" }),
createMapping({ elementId: "el-date", hubFieldType: "date" }),
createMapping({ elementId: "el-bool", hubFieldType: "boolean" }),
createMapping({ elementId: "el-multi", hubFieldType: "categorical" }),
];
describe("transformResponseToFeedbackRecords", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns empty array when response has no data", () => {
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
expect(result).toEqual([]);
});
test("returns empty array when no mappings match the survey", () => {
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
expect(result).toEqual([]);
});
test("skips elements with empty string values", () => {
const response = {
...mockResponse,
data: { "el-text": "" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result).toEqual([]);
});
test("skips elements with undefined values", () => {
const response = {
...mockResponse,
data: { "el-nps": 9 },
} as unknown as TResponse;
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-nps");
});
test("transforms text field correctly", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks",
field_id: "el-text",
field_type: "text",
field_label: "How can we improve?",
source_id: "survey-1",
source_name: "Product Feedback",
value_text: "Great product!",
language: "en",
user_identifier: "user-42",
});
});
test("transforms nps field correctly", () => {
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(9);
expect(result[0].field_type).toBe("nps");
});
test("transforms rating field correctly", () => {
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(4);
});
test("transforms date field to ISO string", () => {
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
});
test("transforms boolean field correctly", () => {
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_boolean).toBe(true);
});
test("transforms categorical (multi-select) field to comma-separated text", () => {
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("feat-a, feat-b");
});
test("uses customFieldLabel when provided", () => {
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].field_label).toBe("Custom Label");
});
test("sets collected_at from response createdAt", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].collected_at).toBe(NOW.toISOString());
});
test("includes tenant_id when provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
expect(result[0].tenant_id).toBe("tenant-abc");
});
test("omits tenant_id when not provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].tenant_id).toBeUndefined();
});
test("omits language when response language is 'default'", () => {
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].language).toBeUndefined();
});
test("omits user_identifier when contact has no userId", () => {
const response = { ...mockResponse, contact: null } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].user_identifier).toBeUndefined();
});
test("transforms all mappings in a single call", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
expect(result).toHaveLength(6);
const fieldIds = result.map((r) => r.field_id);
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
});
test("falls back to 'Untitled' for element with no headline", () => {
const survey = {
...mockSurvey,
blocks: [{ elements: [{ id: "el-bare", type: "openText" }] }],
} as unknown as TSurvey;
const response = {
...mockResponse,
data: { "el-bare": "some text" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, survey, mappings);
expect(result[0].field_label).toBe("Untitled");
});
describe("convertValueToHubFields edge cases", () => {
test("parses numeric string for nps field", () => {
const response = {
...mockResponse,
data: { "el-nps": "7" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_number).toBe(7);
});
test("returns empty fields for non-parseable numeric string", () => {
const response = {
...mockResponse,
data: { "el-nps": "not-a-number" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_number).toBeUndefined();
});
test("handles object value for text field", () => {
const response = {
...mockResponse,
data: { "el-text": { nested: "value" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
});
test("handles invalid date string gracefully", () => {
const response = {
...mockResponse,
data: { "el-date": "not-a-date" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_date).toBeUndefined();
});
test("converts boolean string '1' to true", () => {
const response = {
...mockResponse,
data: { "el-bool": "1" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_boolean).toBe(true);
});
test("converts boolean string 'false' to false", () => {
const response = {
...mockResponse,
data: { "el-bool": "false" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_boolean).toBe(false);
});
test("handles array value for text field", () => {
const response = {
...mockResponse,
data: { "el-text": ["a", "b", "c"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe("a, b, c");
});
test("handles single string value for categorical field", () => {
const response = {
...mockResponse,
data: { "el-multi": "single-choice" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].value_text).toBe("single-choice");
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import "server-only";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { TCreateFeedbackRecordInput } from "./hub-client";
type TResponseValue = string | number | string[] | Record<string, string> | undefined;
type TSurveyElement = ReturnType<typeof getElementsFromBlocks>[number];
const getHeadlineFromElement = (element: TSurveyElement | undefined): string => {
if (!element?.headline) return "Untitled";
const raw = getLocalizedValue(element.headline, "default");
return getTextContent(raw) || "Untitled";
};
function extractResponseValue(responseData: TResponse["data"], elementId: string): TResponseValue {
if (!responseData || typeof responseData !== "object") return undefined;
return (responseData as Record<string, TResponseValue>)[elementId];
}
const convertValueToHubFields = (
value: TResponseValue,
hubFieldType: THubFieldType
): Partial<
Pick<TCreateFeedbackRecordInput, "value_text" | "value_number" | "value_boolean" | "value_date">
> => {
if (value === undefined || value === null) {
return {};
}
switch (hubFieldType) {
case "text":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
case "number":
case "rating":
case "nps":
case "csat":
case "ces":
if (typeof value === "number") return { value_number: value };
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (!Number.isNaN(parsed)) return { value_number: parsed };
}
return {};
case "boolean":
if (typeof value === "boolean") return { value_boolean: value };
if (typeof value === "string") {
return { value_boolean: value.toLowerCase() === "true" || value === "1" };
}
return {};
case "date":
if (typeof value === "string") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return { value_date: date.toISOString() };
}
if (value instanceof Date) return { value_date: value.toISOString() };
return {};
case "categorical":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
return { value_text: String(value) };
default:
return { value_text: typeof value === "string" ? value : String(value) };
}
};
/**
* Transform a Formbricks survey response into Hub FeedbackRecord payloads.
* Called from the pipeline handler when a response is created/finished.
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId?: string
): TCreateFeedbackRecordInput[] {
const responseData = response.data;
if (!responseData) return [];
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
if (value === undefined || value === null || value === "") continue;
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord: TCreateFeedbackRecordInput = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",
field_id: mapping.elementId,
field_type: mapping.hubFieldType,
source_id: survey.id,
source_name: survey.name,
field_label: fieldLabel,
...valueFields,
};
if (response.language && response.language !== "default") {
feedbackRecord.language = response.language;
}
if (tenantId) {
feedbackRecord.tenant_id = tenantId;
}
if (response.contact?.userId) {
feedbackRecord.user_identifier = response.contact.userId;
}
feedbackRecords.push(feedbackRecord);
}
return feedbackRecords;
}
+3
View File
@@ -41,6 +41,9 @@ export const GITHUB_SECRET = env.GITHUB_SECRET;
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
export const HUB_API_URL = env.HUB_API_URL;
export const HUB_API_KEY = env.HUB_API_KEY;
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;
export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID;
+4
View File
@@ -33,6 +33,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
HUB_API_URL: z.string().url().optional(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.string()
.url()
@@ -161,6 +163,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
HUB_API_URL: process.env.HUB_API_URL,
HUB_API_KEY: process.env.HUB_API_KEY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
+1 -2
View File
@@ -180,8 +180,7 @@ export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Recor
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
+49
View File
@@ -9,6 +9,7 @@ import {
getFormattedErrorMessage,
getOrganizationIdFromActionClassId,
getOrganizationIdFromApiKeyId,
getOrganizationIdFromConnectorId,
getOrganizationIdFromContactId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
@@ -24,6 +25,7 @@ import {
getOrganizationIdFromWebhookId,
getProductIdFromContactId,
getProjectIdFromActionClassId,
getProjectIdFromConnectorId,
getProjectIdFromContactId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
@@ -54,6 +56,7 @@ vi.mock("@/lib/utils/services", () => ({
getLanguage: vi.fn(),
getTeam: vi.fn(),
getTag: vi.fn(),
getConnector: vi.fn(),
}));
describe("Helper Utilities", () => {
@@ -382,6 +385,31 @@ describe("Helper Utilities", () => {
const orgId = await getOrganizationIdFromQuotaId("quota1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromConnectorId returns organization ID through environment and project", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromConnectorId("connector1");
expect(orgId).toBe("org1");
expect(services.getConnector).toHaveBeenCalledWith("connector1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
expect(services.getProject).toHaveBeenCalledWith("project1");
});
test("getOrganizationIdFromConnectorId throws error when connector not found", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(services.getConnector).toHaveBeenCalledWith("nonexistent");
});
});
describe("Project ID retrieval functions", () => {
@@ -587,6 +615,27 @@ describe("Helper Utilities", () => {
const projectId = await getProjectIdFromQuotaId("quota1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromConnectorId returns project ID through environment", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromConnectorId("connector1");
expect(projectId).toBe("project1");
expect(services.getConnector).toHaveBeenCalledWith("connector1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
});
test("getProjectIdFromConnectorId throws error when connector not found", async () => {
vi.mocked(services.getConnector).mockResolvedValueOnce(null);
await expect(getProjectIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(services.getConnector).toHaveBeenCalledWith("nonexistent");
});
});
describe("Environment ID retrieval functions", () => {
+20
View File
@@ -2,6 +2,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
getActionClass,
getApiKey,
getConnector,
getContact,
getEnvironment,
getIntegration,
@@ -329,3 +330,22 @@ export const isStringMatch = (query: string, value: string): boolean => {
return valueModified.includes(queryModified);
};
// Connector helpers
export const getOrganizationIdFromConnectorId = async (connectorId: string) => {
const connector = await getConnector(connectorId);
if (!connector) {
throw new ResourceNotFoundError("connector", connectorId);
}
return await getOrganizationIdFromEnvironmentId(connector.environmentId);
};
export const getProjectIdFromConnectorId = async (connectorId: string) => {
const connector = await getConnector(connectorId);
if (!connector) {
throw new ResourceNotFoundError("connector", connectorId);
}
return await getProjectIdFromEnvironmentId(connector.environmentId);
};
+46
View File
@@ -8,6 +8,7 @@ import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
import {
getActionClass,
getApiKey,
getConnector,
getContact,
getEnvironment,
getIntegration,
@@ -78,6 +79,9 @@ vi.mock("@formbricks/database", () => ({
contact: {
findUnique: vi.fn(),
},
connector: {
findUnique: vi.fn(),
},
segment: {
findUnique: vi.fn(),
},
@@ -556,4 +560,46 @@ describe("Service Functions", () => {
await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError);
});
});
describe("getConnector", () => {
const connectorId = "connector123";
test("returns the connector when found", async () => {
const mockConnector = { environmentId: "env123" };
vi.mocked(prisma.connector.findUnique).mockResolvedValue(mockConnector);
const result = await getConnector(connectorId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.connector.findUnique).toHaveBeenCalledWith({
where: { id: connectorId },
select: { environmentId: true },
});
expect(result).toEqual(mockConnector);
});
test("returns null when connector not found", async () => {
vi.mocked(prisma.connector.findUnique).mockResolvedValue(null);
const result = await getConnector(connectorId);
expect(result).toBeNull();
});
test("throws DatabaseError when Prisma throws a known request error", async () => {
vi.mocked(prisma.connector.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getConnector(connectorId)).rejects.toThrow(DatabaseError);
});
test("rethrows unknown errors", async () => {
const unknownError = new Error("Something unexpected");
vi.mocked(prisma.connector.findUnique).mockRejectedValue(unknownError);
await expect(getConnector(connectorId)).rejects.toThrow(unknownError);
});
});
});
+22
View File
@@ -329,3 +329,25 @@ export const getSegment = reactCache(async (segmentId: string): Promise<{ enviro
throw error;
}
});
export const getConnector = reactCache(
async (connectorId: string): Promise<{ environmentId: string } | null> => {
validateInputs([connectorId, ZId]);
try {
const connector = await prisma.connector.findUnique({
where: {
id: connectorId,
},
select: { environmentId: true },
});
return connector;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "App-Umfrage",
"apply_filters": "Filter anwenden",
"are_you_sure": "Bist Du sicher?",
"ask": "Fragen",
"attributes": "Attribute",
"back": "Zurück",
"billing": "Abrechnung",
@@ -163,7 +164,7 @@
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"completed": "Abgeschlossen",
"configuration": "Konfiguration",
"configure": "Konfigurieren",
"confirm": "Bestätigen",
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
@@ -199,6 +200,7 @@
"disallow": "Nicht erlauben",
"discard": "Verwerfen",
"dismissed": "Entlassen",
"distribute": "Verteilen",
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domain",
@@ -267,6 +269,7 @@
"logout": "Abmelden",
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"mappings": "Zuordnungen",
"marketing": "Marketing",
"member": "Mitglied",
"members": "Mitglieder",
@@ -428,6 +431,7 @@
"top_right": "Oben rechts",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unify": "Vereinheitlichen",
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
"update": "Aktualisierung",
"updated": "Aktualisiert",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Diese Umfrage verwendet Logik."
}
},
"unify": {
"add_feedback_source": "Feedback-Quelle hinzufügen",
"add_source": "Quelle hinzufügen",
"are_you_sure": "Bist Du sicher?",
"automated": "Automatisiert",
"aws_region": "AWS-Region",
"change_file": "Datei ändern",
"click_load_sample_csv": "Klicke auf 'Beispiel-CSV laden', um Spalten zu sehen",
"click_to_upload": "Klicke zum Hochladen",
"configure_import": "Import konfigurieren",
"configure_mapping": "Zuordnung konfigurieren",
"connection": "Verbindung",
"connector_created_successfully": "Connector erfolgreich erstellt",
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
"connector_updated_successfully": "Connector erfolgreich aktualisiert",
"copied": "Kopiert!",
"copy": "Kopieren",
"create_mapping": "Zuordnung erstellen",
"csv_at_least_one_row": "CSV muss mindestens eine Datenzeile enthalten.",
"csv_columns": "CSV-Spalten",
"csv_empty_column_headers": "CSV enthält leere Spaltenüberschriften. Alle Spalten müssen einen Namen haben.",
"csv_file_too_large": "Die CSV-Datei ist zu groß. Die maximale Größe beträgt 2 MB.",
"csv_files_only": "Nur CSV-Dateien",
"csv_import": "CSV-Import",
"csv_inconsistent_columns": "Zeile {{row}} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
"csv_max_records": "Maximal {{max}} Datensätze erlaubt.",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks Umfrage-Verbindung",
"delete_source": "Quelle löschen",
"deselect_all": "Alle abwählen",
"drop_a_field_here": "Feld hier ablegen",
"drop_field_or": "Feld ablegen oder",
"drop_zone_path": "Ablagebereich-Pfad",
"edit_source_connection": "Quellverbindung bearbeiten",
"element_selected": "<strong>{count}</strong> Element ausgewählt. Jede Antwort auf dieses Element erstellt einen FeedbackRecord im Hub.",
"elements_selected": "<strong>{count}</strong> Elemente ausgewählt. Jede Antwort auf diese Elemente erstellt einen FeedbackRecord im Hub.",
"enable_auto_sync": "Auto-Sync aktivieren",
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Enum",
"every_15_minutes": "Alle 15 Minuten",
"every_30_minutes": "Alle 30 Minuten",
"every_5_minutes": "Alle 5 Minuten",
"every_hour": "Jede Stunde",
"feedback_date": "Feedback-Datum",
"field": "Feld",
"fields": "Felder",
"formbricks_surveys": "Formbricks Umfragen",
"hub_feedback_record_fields": "Hub Feedback-Record-Felder",
"iam_configuration_required": "IAM-Konfiguration erforderlich",
"iam_setup_instructions": "Füge die Formbricks IAM-Rolle zu deiner S3-Bucket-Policy hinzu, um den Zugriff zu ermöglichen.",
"import_csv_data": "CSV-Daten importieren",
"load_sample_csv": "Beispiel-CSV laden",
"n_elements": "{count} Elemente",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
"optional": "Optional",
"or": "oder",
"or_drag_and_drop": "oder per Drag & Drop",
"process_new_files_description": "Neue Dateien, die im Bucket abgelegt werden, automatisch verarbeiten",
"processing_interval": "Verarbeitungsintervall",
"region_ap_southeast_1": "Asien-Pazifik (Singapur)",
"region_eu_central_1": "EU (Frankfurt)",
"region_eu_west_1": "EU (Irland)",
"region_us_east_1": "US Ost (N. Virginia)",
"region_us_west_2": "US West (Oregon)",
"required": "Erforderlich",
"s3_bucket_description": "Lege CSV-Dateien in deinem S3-Bucket ab, um Feedback automatisch zu importieren. Dateien werden alle 15 Minuten verarbeitet.",
"s3_bucket_integration": "S3-Bucket-Integration",
"save_changes": "Änderungen speichern",
"select_a_survey_to_see_elements": "Wähle eine Umfrage aus, um ihre Elemente zu sehen",
"select_a_value": "Wähle einen Wert aus...",
"select_all": "Alles auswählen",
"select_elements": "Elemente auswählen",
"select_questions": "Fragen auswählen",
"select_source_type_description": "Wähle den Typ der Feedbackquelle aus, die du verbinden möchtest.",
"select_source_type_prompt": "Wähle den Typ der Feedbackquelle, die du verbinden möchtest:",
"select_survey": "Umfrage auswählen",
"select_survey_and_questions": "Umfrage & Fragen auswählen",
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
"set_value": "Wert festlegen",
"setup_connection": "Verbindung einrichten",
"showing_rows": "Zeige 3 von {count} Zeilen",
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
"source_connect_formbricks_description": "Feedback aus deinen Formbricks-Umfragen verbinden",
"source_fields": "Quellenfelder",
"source_name": "Quellenname",
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"sources": "Quellen",
"status_active": "Aktiv",
"status_completed": "Abgeschlossen",
"status_draft": "Entwurf",
"status_error": "Fehler",
"status_paused": "Pausiert",
"survey_has_no_elements": "Diese Umfrage hat keine Fragen",
"test_connection": "Verbindung testen",
"unify_feedback": "Feedback vereinheitlichen",
"update_mapping_description": "Aktualisiere die Mapping-Konfiguration für diese Quelle.",
"upload_csv_data_description": "Lade eine CSV-Datei hoch oder richte automatisierte S3-Importe ein.",
"upload_csv_file": "CSV-Datei hochladen",
"view_setup_guide": "Setup-Anleitung ansehen →",
"yes_delete": "Ja, löschen"
},
"workspace": {
"api_keys": {
"add_api_key": "API-Schlüssel hinzufügen",
+115 -7
View File
@@ -49,7 +49,7 @@
"invite_not_found": "Invite not found 😥",
"invite_not_found_description": "The invitation code cannot be found or has already been used.",
"login": "Login",
"welcome_to_organization": "You are in \uD83C\uDF89",
"welcome_to_organization": "You are in 🎉",
"welcome_to_organization_description": "Welcome to the organization."
},
"last_used": "Last Used",
@@ -141,6 +141,7 @@
"app_survey": "App Survey",
"apply_filters": "Apply filters",
"are_you_sure": "Are you sure?",
"ask": "Ask",
"attributes": "Attributes",
"back": "Back",
"billing": "Billing",
@@ -163,7 +164,7 @@
"code": "Code",
"collapse_rows": "Collapse rows",
"completed": "Completed",
"configuration": "Configuration",
"configure": "Configure",
"confirm": "Confirm",
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Do not allow",
"discard": "Discard",
"dismissed": "Dismissed",
"distribute": "Distribute",
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domain",
@@ -267,6 +269,7 @@
"logout": "Logout",
"look_and_feel": "Look & Feel",
"manage": "Manage",
"mappings": "Mappings",
"marketing": "Marketing",
"member": "Member",
"members": "Members",
@@ -428,6 +431,7 @@
"top_right": "Top Right",
"try_again": "Try again",
"type": "Type",
"unify": "Unify",
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
"update": "Update",
"updated": "Updated",
@@ -517,7 +521,7 @@
"select_a_date": "Select a date",
"survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
"survey_response_finished_email_dont_want_notifications": "Do not want to get these notifications?",
"survey_response_finished_email_hey": "Hey \uD83D\uDC4B",
"survey_response_finished_email_hey": "Hey 👋",
"survey_response_finished_email_turn_off_notifications_for_all_new_forms": "Turn off notifications for all newly created forms",
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "This survey uses branching logic."
}
},
"unify": {
"add_feedback_source": "Add Feedback Source",
"add_source": "Add source",
"are_you_sure": "Are you sure?",
"automated": "Automated",
"aws_region": "AWS Region",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"configure_import": "Configure import",
"configure_mapping": "Configure Mapping",
"connection": "Connection",
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_updated_successfully": "Connector updated successfully",
"copied": "Copied!",
"copy": "Copy",
"create_mapping": "Create mapping",
"csv_at_least_one_row": "CSV must contain at least one data row.",
"csv_columns": "CSV Columns",
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_import": "CSV Import",
"csv_inconsistent_columns": "Row {{row}} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {{max}} records allowed.",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"delete_source": "Delete source",
"deselect_all": "Deselect all",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"drop_zone_path": "Drop zone path",
"edit_source_connection": "Edit Source Connection",
"element_selected": "<strong>{count}</strong> element selected. Each response to these elements will create a FeedbackRecord in the Hub.",
"elements_selected": "<strong>{count}</strong> elements selected. Each response to these elements will create a FeedbackRecord in the Hub.",
"enable_auto_sync": "Enable auto-sync",
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"every_15_minutes": "Every 15 minutes",
"every_30_minutes": "Every 30 minutes",
"every_5_minutes": "Every 5 minutes",
"every_hour": "Every hour",
"feedback_date": "Feedback date",
"field": "field",
"fields": "fields",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Hub Feedback Record Fields",
"iam_configuration_required": "IAM Configuration Required",
"iam_setup_instructions": "Add the Formbricks IAM role to your S3 bucket policy to enable access.",
"import_csv_data": "Import CSV Data",
"load_sample_csv": "Load sample CSV",
"n_elements": "{count} elements",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"no_surveys_found": "No surveys found in this environment",
"optional": "Optional",
"or": "or",
"or_drag_and_drop": "or drag and drop",
"process_new_files_description": "Automatically process new files dropped in the bucket",
"processing_interval": "Processing interval",
"region_ap_southeast_1": "Asia Pacific (Singapore)",
"region_eu_central_1": "EU (Frankfurt)",
"region_eu_west_1": "EU (Ireland)",
"region_us_east_1": "US East (N. Virginia)",
"region_us_west_2": "US West (Oregon)",
"required": "Required",
"s3_bucket_description": "Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15 minutes.",
"s3_bucket_integration": "S3 Bucket Integration",
"save_changes": "Save changes",
"select_a_survey_to_see_elements": "Select a survey to see its elements",
"select_a_value": "Select a value...",
"select_all": "Select all",
"select_elements": "Select Elements",
"select_questions": "Select questions",
"select_source_type_description": "Select the type of feedback source you want to connect.",
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
"select_survey": "Select Survey",
"select_survey_and_questions": "Select Survey & Questions",
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
"set_value": "set value",
"setup_connection": "Setup connection",
"showing_rows": "Showing 3 of {count} rows",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_fields": "Source Fields",
"source_name": "Source Name",
"source_type_cannot_be_changed": "Source type cannot be changed",
"sources": "Sources",
"status_active": "Active",
"status_completed": "Completed",
"status_draft": "Draft",
"status_error": "Error",
"status_paused": "Paused",
"survey_has_no_elements": "This survey has no question elements",
"test_connection": "Test connection",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Update the mapping configuration for this source.",
"upload_csv_data_description": "Upload a CSV file or set up automated S3 imports.",
"upload_csv_file": "Upload CSV File",
"view_setup_guide": "View setup guide →",
"yes_delete": "Yes, delete"
},
"workspace": {
"api_keys": {
"add_api_key": "Add API Key",
@@ -2671,7 +2779,7 @@
"evaluate_a_product_idea_name": "Evaluate a Product Idea",
"evaluate_a_product_idea_question_1_button_label": "Lets do it!",
"evaluate_a_product_idea_question_1_headline": "We love how you use $[projectName]! We would love to pick your brain on a feature idea. Got a minute?",
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We respect your time and kept it short \uD83E\uDD38</span></p>",
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We respect your time and kept it short 🤸</span></p>",
"evaluate_a_product_idea_question_2_headline": "Thanks! How difficult or easy is it for you to [PROBLEM AREA] today?",
"evaluate_a_product_idea_question_2_lower_label": "Very difficult",
"evaluate_a_product_idea_question_2_upper_label": "Very easy",
@@ -2720,8 +2828,8 @@
"feature_chaser_question_2_headline": "Which aspect is most important?",
"feedback_box_description": "Give your users the chance to seamlessly share what is on their minds.",
"feedback_box_name": "Feedback Box",
"feedback_box_question_1_choice_1": "Bug report \uD83D\uDC1E",
"feedback_box_question_1_choice_2": "Feature Request \uD83D\uDCA1",
"feedback_box_question_1_choice_1": "Bug report 🐞",
"feedback_box_question_1_choice_2": "Feature Request 💡",
"feedback_box_question_1_headline": "What is on your mind, boss?",
"feedback_box_question_1_subheader": "Thanks for sharing. We will get back to you asap.",
"feedback_box_question_2_headline": "What is broken?",
@@ -2842,7 +2950,7 @@
"interview_prompt_description": "Invite a specific subset of your users to schedule an interview with your product team.",
"interview_prompt_name": "Interview Prompt",
"interview_prompt_question_1_button_label": "Book slot",
"interview_prompt_question_1_headline": "Do you have 15 min to talk to us? \uD83D\uDE4F",
"interview_prompt_question_1_headline": "Do you have 15 min to talk to us? 🙏",
"interview_prompt_question_1_html": "You are one of our power users. We would love to interview you briefly!",
"long_term_retention_check_in_description": "Gauge long-term user satisfaction, loyalty, and areas for improvement to retain loyal users.",
"long_term_retention_check_in_name": "Long-Term Retention Check-In",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Encuesta de aplicación",
"apply_filters": "Aplicar filtros",
"are_you_sure": "¿Estás seguro?",
"ask": "Preguntar",
"attributes": "Atributos",
"back": "Atrás",
"billing": "Facturación",
@@ -163,7 +164,7 @@
"code": "Código",
"collapse_rows": "Contraer filas",
"completed": "Completado",
"configuration": "Configuración",
"configure": "Configurar",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
@@ -199,6 +200,7 @@
"disallow": "No permitir",
"discard": "Descartar",
"dismissed": "Descartado",
"distribute": "Distribuir",
"docs": "Documentación",
"documentation": "Documentación",
"domain": "Dominio",
@@ -267,6 +269,7 @@
"logout": "Cerrar sesión",
"look_and_feel": "Apariencia",
"manage": "Gestionar",
"mappings": "Asignaciones",
"marketing": "Marketing",
"member": "Miembro",
"members": "Miembros",
@@ -428,6 +431,7 @@
"top_right": "Superior derecha",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unify": "Unificar",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Esta encuesta utiliza lógica de ramificación."
}
},
"unify": {
"add_feedback_source": "Añadir fuente de feedback",
"add_source": "Añadir fuente",
"are_you_sure": "¿Estás seguro?",
"automated": "Automatizado",
"aws_region": "Región de AWS",
"change_file": "Cambiar archivo",
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
"click_to_upload": "Haz clic para subir",
"configure_import": "Configurar importación",
"configure_mapping": "Configurar asignación",
"connection": "Conexión",
"connector_created_successfully": "Conector creado correctamente",
"connector_deleted_successfully": "Conector eliminado correctamente",
"connector_updated_successfully": "Conector actualizado correctamente",
"copied": "¡Copiado!",
"copy": "Copiar",
"create_mapping": "Crear asignación",
"csv_at_least_one_row": "El CSV debe contener al menos una fila de datos.",
"csv_columns": "Columnas CSV",
"csv_empty_column_headers": "El CSV contiene encabezados de columna vacíos. Todas las columnas deben tener un nombre.",
"csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.",
"csv_files_only": "Solo archivos CSV",
"csv_import": "Importación CSV",
"csv_inconsistent_columns": "La fila {{row}} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
"csv_max_records": "Máximo de {{max}} registros permitidos.",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"delete_source": "Eliminar fuente",
"deselect_all": "Deseleccionar todo",
"drop_a_field_here": "Suelta un campo aquí",
"drop_field_or": "Suelta el campo o",
"drop_zone_path": "Ruta de la zona de destino",
"edit_source_connection": "Editar conexión de origen",
"element_selected": "<strong>{count}</strong> elemento seleccionado. Cada respuesta a este elemento creará un FeedbackRecord en el Hub.",
"elements_selected": "<strong>{count}</strong> elementos seleccionados. Cada respuesta a estos elementos creará un FeedbackRecord en el Hub.",
"enable_auto_sync": "Activar sincronización automática",
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"every_15_minutes": "Cada 15 minutos",
"every_30_minutes": "Cada 30 minutos",
"every_5_minutes": "Cada 5 minutos",
"every_hour": "Cada hora",
"feedback_date": "Fecha del feedback",
"field": "campo",
"fields": "campos",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Campos de FeedbackRecord del Hub",
"iam_configuration_required": "Se requiere configuración de IAM",
"iam_setup_instructions": "Añade el rol de IAM de Formbricks a la política de tu bucket de S3 para habilitar el acceso.",
"import_csv_data": "Importar datos CSV",
"load_sample_csv": "Cargar CSV de muestra",
"n_elements": "{count} elementos",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"no_surveys_found": "No se encontraron encuestas en este entorno",
"optional": "Opcional",
"or": "o",
"or_drag_and_drop": "o arrastra y suelta",
"process_new_files_description": "Procesar automáticamente los archivos nuevos depositados en el bucket",
"processing_interval": "Intervalo de procesamiento",
"region_ap_southeast_1": "Asia Pacífico (Singapur)",
"region_eu_central_1": "UE (Fráncfort)",
"region_eu_west_1": "UE (Irlanda)",
"region_us_east_1": "EE. UU. Este (N. Virginia)",
"region_us_west_2": "EE. UU. Oeste (Oregón)",
"required": "Obligatorio",
"s3_bucket_description": "Deposita archivos CSV en tu bucket de S3 para importar comentarios automáticamente. Los archivos se procesan cada 15 minutos.",
"s3_bucket_integration": "Integración con bucket de S3",
"save_changes": "Guardar cambios",
"select_a_survey_to_see_elements": "Selecciona una encuesta para ver sus elementos",
"select_a_value": "Selecciona un valor...",
"select_all": "Seleccionar todo",
"select_elements": "Seleccionar elementos",
"select_questions": "Seleccionar preguntas",
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
"select_survey": "Seleccionar encuesta",
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
"set_value": "establecer valor",
"setup_connection": "Configurar conexión",
"showing_rows": "Mostrando 3 de {count} filas",
"source_connect_csv_description": "Importar feedback desde archivos CSV",
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
"source_fields": "Campos de origen",
"source_name": "Nombre de origen",
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"sources": "Orígenes",
"status_active": "Activo",
"status_completed": "Completado",
"status_draft": "Borrador",
"status_error": "Error",
"status_paused": "Pausado",
"survey_has_no_elements": "Esta encuesta no tiene elementos de pregunta",
"test_connection": "Probar conexión",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
"upload_csv_data_description": "Sube un archivo CSV o configura importaciones automatizadas desde S3.",
"upload_csv_file": "Subir archivo CSV",
"view_setup_guide": "Ver guía de configuración →",
"yes_delete": "Sí, eliminar"
},
"workspace": {
"api_keys": {
"add_api_key": "Añadir clave API",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Sondage d'application",
"apply_filters": "Appliquer des filtres",
"are_you_sure": "Es-tu sûr ?",
"ask": "Demander",
"attributes": "Attributs",
"back": "Retour",
"billing": "Facturation",
@@ -163,7 +164,7 @@
"code": "Code",
"collapse_rows": "Réduire les lignes",
"completed": "Terminé",
"configuration": "Configuration",
"configure": "Configurer",
"confirm": "Confirmer",
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Ne pas autoriser",
"discard": "Annuler",
"dismissed": "Rejeté",
"distribute": "Distribuer",
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domaine",
@@ -267,6 +269,7 @@
"logout": "Déconnexion",
"look_and_feel": "Apparence",
"manage": "Gérer",
"mappings": "Mappages",
"marketing": "Marketing",
"member": "Membre",
"members": "Membres",
@@ -428,6 +431,7 @@
"top_right": "En haut à droite",
"try_again": "Réessayer",
"type": "Type",
"unify": "Unifier",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Cette enquête utilise une logique de branchement."
}
},
"unify": {
"add_feedback_source": "Ajouter une source de feedback",
"add_source": "Ajouter une source",
"are_you_sure": "Es-tu sûr?",
"automated": "Automatisé",
"aws_region": "Région AWS",
"change_file": "Changer de fichier",
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
"click_to_upload": "Clique pour charger",
"configure_import": "Configurer l'importation",
"configure_mapping": "Configurer le mappage",
"connection": "Connexion",
"connector_created_successfully": "Connecteur créé avec succès",
"connector_deleted_successfully": "Connecteur supprimé avec succès",
"connector_updated_successfully": "Connecteur mis à jour avec succès",
"copied": "Copié !",
"copy": "Copier",
"create_mapping": "Créer un mappage",
"csv_at_least_one_row": "Le CSV doit contenir au moins une ligne de données.",
"csv_columns": "Colonnes CSV",
"csv_empty_column_headers": "Le CSV contient des en-têtes de colonnes vides. Toutes les colonnes doivent avoir un nom.",
"csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.",
"csv_files_only": "Fichiers CSV uniquement",
"csv_import": "Importation CSV",
"csv_inconsistent_columns": "La ligne {{row}} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
"csv_max_records": "Maximum {{max}} enregistrements autorisés.",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"delete_source": "Supprimer la source",
"deselect_all": "Tout désélectionner",
"drop_a_field_here": "Déposez un champ ici",
"drop_field_or": "Déposez un champ ou",
"drop_zone_path": "Chemin de la zone de dépôt",
"edit_source_connection": "Modifier la connexion source",
"element_selected": "<strong>{count}</strong> élément sélectionné. Chaque réponse à cet élément créera un FeedbackRecord dans le Hub.",
"elements_selected": "<strong>{count}</strong> éléments sélectionnés. Chaque réponse à ces éléments créera un FeedbackRecord dans le Hub.",
"enable_auto_sync": "Activer la synchronisation automatique",
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"every_15_minutes": "Toutes les 15 minutes",
"every_30_minutes": "Toutes les 30 minutes",
"every_5_minutes": "Toutes les 5 minutes",
"every_hour": "Toutes les heures",
"feedback_date": "Date du feedback",
"field": "champ",
"fields": "champs",
"formbricks_surveys": "Sondages Formbricks",
"hub_feedback_record_fields": "Champs d'enregistrement de feedback du Hub",
"iam_configuration_required": "Configuration IAM requise",
"iam_setup_instructions": "Ajoutez le rôle IAM Formbricks à la politique de votre bucket S3 pour activer l'accès.",
"import_csv_data": "Importer des données CSV",
"load_sample_csv": "Charger un exemple de CSV",
"n_elements": "{count, plural, one {# élément} other {# éléments}}",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
"optional": "Facultatif",
"or": "ou",
"or_drag_and_drop": "ou glisser-déposer",
"process_new_files_description": "Traiter automatiquement les nouveaux fichiers déposés dans le bucket",
"processing_interval": "Intervalle de traitement",
"region_ap_southeast_1": "Asie-Pacifique (Singapour)",
"region_eu_central_1": "UE (Francfort)",
"region_eu_west_1": "UE (Irlande)",
"region_us_east_1": "Est des États-Unis (Virginie du Nord)",
"region_us_west_2": "Ouest des États-Unis (Oregon)",
"required": "Requis",
"s3_bucket_description": "Déposez des fichiers CSV dans votre bucket S3 pour importer automatiquement les retours. Les fichiers sont traités toutes les 15 minutes.",
"s3_bucket_integration": "Intégration de bucket S3",
"save_changes": "Enregistrer les modifications",
"select_a_survey_to_see_elements": "Sélectionnez une enquête pour voir ses éléments",
"select_a_value": "Sélectionnez une valeur...",
"select_all": "Sélectionner tout",
"select_elements": "Sélectionner les éléments",
"select_questions": "Sélectionner les questions",
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter:",
"select_survey": "Sélectionner l'enquête",
"select_survey_and_questions": "Sélectionner l'enquête et les questions",
"select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.",
"set_value": "définir la valeur",
"setup_connection": "Configurer la connexion",
"showing_rows": "Affichage de 3 sur {count} lignes",
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
"source_fields": "Champs source",
"source_name": "Nom de la source",
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
"sources": "Sources",
"status_active": "Active",
"status_completed": "Terminé",
"status_draft": "Brouillon",
"status_error": "Erreur",
"status_paused": "En pause",
"survey_has_no_elements": "Cette enquête n'a aucun élément de question",
"test_connection": "Tester la connexion",
"unify_feedback": "Unifier les retours",
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
"upload_csv_data_description": "Télécharger un fichier CSV ou configurer des imports S3 automatisés.",
"upload_csv_file": "Télécharger un fichier CSV",
"view_setup_guide": "Voir le guide de configuration →",
"yes_delete": "Oui, supprimer"
},
"workspace": {
"api_keys": {
"add_api_key": "Ajouter une clé API",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Alkalmazás-kérdőív",
"apply_filters": "Szűrők alkalmazása",
"are_you_sure": "Biztos benne?",
"ask": "Kérdezz",
"attributes": "Attribútumok",
"back": "Vissza",
"billing": "Számlázás",
@@ -163,7 +164,7 @@
"code": "Kód",
"collapse_rows": "Sorok összecsukása",
"completed": "Befejezve",
"configuration": "Beállítás",
"configure": "Konfigurálás",
"confirm": "Megerősítés",
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
@@ -199,6 +200,7 @@
"disallow": "Ne engedélyezze",
"discard": "Elvetés",
"dismissed": "Eltüntetve",
"distribute": "Oszd meg",
"docs": "Dokumentáció",
"documentation": "Dokumentáció",
"domain": "Tartomány",
@@ -267,6 +269,7 @@
"logout": "Kijelentkezés",
"look_and_feel": "Megjelenés",
"manage": "Kezelés",
"mappings": "Leképezések",
"marketing": "Marketing",
"member": "Tag",
"members": "Tagok",
@@ -428,6 +431,7 @@
"top_right": "Jobbra fent",
"try_again": "Próbálja újra",
"type": "Típus",
"unify": "Egyesíts",
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
"update": "Frissítés",
"updated": "Frissítve",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Ez a kérdőív elágazási logikát használ."
}
},
"unify": {
"add_feedback_source": "Visszajelzési forrás hozzáadása",
"add_source": "Forrás hozzáadása",
"are_you_sure": "Biztos benne?",
"automated": "Automatizált",
"aws_region": "AWS régió",
"change_file": "Fájl módosítása",
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
"click_to_upload": "Kattintson a feltöltéshez",
"configure_import": "Importálás konfigurálása",
"configure_mapping": "Leképezés konfigurálása",
"connection": "Kapcsolat",
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
"copied": "Másolva!",
"copy": "Másolás",
"create_mapping": "Leképezés létrehozása",
"csv_at_least_one_row": "A CSV-nek legalább egy adatsort kell tartalmaznia.",
"csv_columns": "CSV oszlopok",
"csv_empty_column_headers": "A CSV üres oszlopfejléceket tartalmaz. Minden oszlopnak rendelkeznie kell névvel.",
"csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.",
"csv_files_only": "Csak CSV fájlok",
"csv_import": "CSV importálás",
"csv_inconsistent_columns": "A(z) {{row}}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
"csv_max_records": "Legfeljebb {{max}} rekord engedélyezett.",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"delete_source": "Forrás törlése",
"deselect_all": "Összes kijelölés törlése",
"drop_a_field_here": "Húzz ide egy mezőt",
"drop_field_or": "Húzz ide egy mezőt vagy",
"drop_zone_path": "Célterület elérési útja",
"edit_source_connection": "Forráskapcsolat szerkesztése",
"element_selected": "<strong>{count}</strong> elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.",
"elements_selected": "<strong>{count}</strong> elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.",
"enable_auto_sync": "Automatikus szinkronizálás engedélyezése",
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"every_15_minutes": "15 percenként",
"every_30_minutes": "30 percenként",
"every_5_minutes": "5 percenként",
"every_hour": "Óránként",
"feedback_date": "Visszajelzés dátuma",
"field": "mező",
"fields": "mezők",
"formbricks_surveys": "Formbricks kérdőívek",
"hub_feedback_record_fields": "Központi visszajelzési rekord mezők",
"iam_configuration_required": "IAM konfiguráció szükséges",
"iam_setup_instructions": "Add hozzá a Formbricks IAM szerepkört az S3 bucket szabályzatodhoz a hozzáférés engedélyezéséhez.",
"import_csv_data": "CSV adatok importálása",
"load_sample_csv": "Minta CSV betöltése",
"n_elements": "{count, plural, one {# elem} other {# elem}}",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
"optional": "Elhagyható",
"or": "vagy",
"or_drag_and_drop": "vagy húzd ide",
"process_new_files_description": "Új fájlok automatikus feldolgozása a tárolóba helyezéskor",
"processing_interval": "Feldolgozási időköz",
"region_ap_southeast_1": "Ázsia-Csendes-óceáni térség (Szingapúr)",
"region_eu_central_1": "EU (Frankfurt)",
"region_eu_west_1": "EU (Írország)",
"region_us_east_1": "USA keleti régió (Észak-Virginia)",
"region_us_west_2": "USA nyugati régió (Oregon)",
"required": "Kötelező",
"s3_bucket_description": "Helyezz CSV fájlokat az S3 tárolódba a visszajelzések automatikus importálásához. A fájlok 15 percenként kerülnek feldolgozásra.",
"s3_bucket_integration": "S3 tároló integráció",
"save_changes": "Változtatások mentése",
"select_a_survey_to_see_elements": "Válassz egy kérdőívet az elemek megtekintéséhez",
"select_a_value": "Válassz egy értéket...",
"select_all": "Összes kiválasztása",
"select_elements": "Elemek kiválasztása",
"select_questions": "Kérdések kiválasztása",
"select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.",
"select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
"select_survey": "Kérdőív kiválasztása",
"select_survey_and_questions": "Kérdőív és kérdések kiválasztása",
"select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.",
"set_value": "érték beállítása",
"setup_connection": "Kapcsolat beállítása",
"showing_rows": "3 megjelenítve {count} sorból",
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
"source_fields": "Forrásmezők",
"source_name": "Forrásnév",
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
"sources": "Források",
"status_active": "Aktív",
"status_completed": "Befejezve",
"status_draft": "Piszkozat",
"status_error": "Hiba",
"status_paused": "Szüneteltetve",
"survey_has_no_elements": "Ez a kérdőív nem tartalmaz kérdéselemeket",
"test_connection": "Kapcsolat tesztelése",
"unify_feedback": "Visszajelzések egyesítése",
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
"upload_csv_data_description": "Töltsön fel egy CSV fájlt, vagy állítson be automatizált S3 importálást.",
"upload_csv_file": "CSV fájl feltöltése",
"view_setup_guide": "Telepítési útmutató megtekintése →",
"yes_delete": "Igen, törlés"
},
"workspace": {
"api_keys": {
"add_api_key": "API-kulcs hozzáadása",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "アプリ内フォーム",
"apply_filters": "フィルターを適用",
"are_you_sure": "よろしいですか?",
"ask": "質問する",
"attributes": "属性",
"back": "戻る",
"billing": "請求",
@@ -163,7 +164,7 @@
"code": "コード",
"collapse_rows": "行を非表示",
"completed": "完了",
"configuration": "設定",
"configure": "設定",
"confirm": "確認",
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
@@ -199,6 +200,7 @@
"disallow": "許可しない",
"discard": "破棄",
"dismissed": "非表示",
"distribute": "配布する",
"docs": "ドキュメント",
"documentation": "ドキュメント",
"domain": "ドメイン",
@@ -267,6 +269,7 @@
"logout": "ログアウト",
"look_and_feel": "デザイン",
"manage": "管理",
"mappings": "マッピング",
"marketing": "マーケティング",
"member": "メンバー",
"members": "メンバー",
@@ -428,6 +431,7 @@
"top_right": "右上",
"try_again": "もう一度お試しください",
"type": "種類",
"unify": "統合する",
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
"update": "更新",
"updated": "更新済み",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "このフォームは分岐ロジックを使用しています。"
}
},
"unify": {
"add_feedback_source": "フィードバックソースを追加",
"add_source": "ソースを追加",
"are_you_sure": "よろしいですか?",
"automated": "自動化",
"aws_region": "AWSリージョン",
"change_file": "ファイルを変更",
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
"click_to_upload": "クリックしてアップロード",
"configure_import": "インポートを設定",
"configure_mapping": "マッピングを設定",
"connection": "接続",
"connector_created_successfully": "コネクタが正常に作成されました",
"connector_deleted_successfully": "コネクタが正常に削除されました",
"connector_updated_successfully": "コネクタが正常に更新されました",
"copied": "コピーしました!",
"copy": "コピー",
"create_mapping": "マッピングを作成",
"csv_at_least_one_row": "CSVには少なくとも1行のデータが必要です。",
"csv_columns": "CSV列",
"csv_empty_column_headers": "CSVに空の列ヘッダーが含まれています。すべての列に名前が必要です。",
"csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。",
"csv_files_only": "CSVファイルのみ",
"csv_import": "CSVインポート",
"csv_inconsistent_columns": "{{row}}行目の列が一致しません。すべての行で同じヘッダーが必要です。",
"csv_max_records": "最大{{max}}件のレコードまで許可されています。",
"default_connector_name_csv": "CSVインポート",
"default_connector_name_formbricks": "Formbricks フォーム接続",
"delete_source": "ソースを削除",
"deselect_all": "すべて選択解除",
"drop_a_field_here": "ここにフィールドをドロップ",
"drop_field_or": "フィールドをドロップまたは",
"drop_zone_path": "ドロップゾーンのパス",
"edit_source_connection": "ソース接続を編集",
"element_selected": "<strong>{count}</strong>個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
"elements_selected": "<strong>{count}</strong>個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
"enable_auto_sync": "自動同期を有効にする",
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"every_15_minutes": "15分ごと",
"every_30_minutes": "30分ごと",
"every_5_minutes": "5分ごと",
"every_hour": "1時間ごと",
"feedback_date": "フィードバック日時",
"field": "フィールド",
"fields": "フィールド",
"formbricks_surveys": "Formbricks フォーム",
"hub_feedback_record_fields": "ハブフィードバックレコードフィールド",
"iam_configuration_required": "IAM設定が必要です",
"iam_setup_instructions": "S3バケットポリシーにFormbricks IAMロールを追加して、アクセスを有効にしてください。",
"import_csv_data": "CSVデータをインポート",
"load_sample_csv": "サンプルCSVを読み込む",
"n_elements": "{count}個の要素",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"no_surveys_found": "この環境にフォームが見つかりません",
"optional": "任意",
"or": "または",
"or_drag_and_drop": "またはドラッグ&ドロップ",
"process_new_files_description": "バケットにドロップされた新しいファイルを自動的に処理します",
"processing_interval": "処理間隔",
"region_ap_southeast_1": "アジアパシフィック(シンガポール)",
"region_eu_central_1": "EU(フランクフルト)",
"region_eu_west_1": "EU(アイルランド)",
"region_us_east_1": "米国東部(バージニア北部)",
"region_us_west_2": "米国西部(オレゴン)",
"required": "必須",
"s3_bucket_description": "S3バケットにCSVファイルをドロップすると、フィードバックが自動的にインポートされます。ファイルは15分ごとに処理されます。",
"s3_bucket_integration": "S3バケット連携",
"save_changes": "変更を保存",
"select_a_survey_to_see_elements": "フォームを選択して要素を表示",
"select_a_value": "値を選択...",
"select_all": "すべて選択",
"select_elements": "要素を選択",
"select_questions": "質問を選択",
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
"select_survey": "フォームを選択",
"select_survey_and_questions": "フォームと質問を選択",
"select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。",
"set_value": "値を設定",
"setup_connection": "接続を設定",
"showing_rows": "{count}行中3行を表示",
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
"source_fields": "ソースフィールド",
"source_name": "ソース名",
"source_type_cannot_be_changed": "ソースタイプは変更できません",
"sources": "ソース",
"status_active": "有効",
"status_completed": "完了",
"status_draft": "下書き",
"status_error": "エラー",
"status_paused": "一時停止",
"survey_has_no_elements": "このフォームには質問要素がありません",
"test_connection": "接続をテスト",
"unify_feedback": "フィードバックを統合",
"update_mapping_description": "このソースのマッピング設定を更新します。",
"upload_csv_data_description": "CSVファイルをアップロードするか、S3の自動インポートを設定します。",
"upload_csv_file": "CSVファイルをアップロード",
"view_setup_guide": "セットアップガイドを見る →",
"yes_delete": "はい、削除します"
},
"workspace": {
"api_keys": {
"add_api_key": "APIキーを追加",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "App-enquête",
"apply_filters": "Pas filters toe",
"are_you_sure": "Weet je het zeker?",
"ask": "Vraag",
"attributes": "Kenmerken",
"back": "Rug",
"billing": "Facturering",
@@ -163,7 +164,7 @@
"code": "Code",
"collapse_rows": "Rijen samenvouwen",
"completed": "Voltooid",
"configuration": "Configuratie",
"configure": "Configureren",
"confirm": "Bevestigen",
"connect": "Verbinden",
"connect_formbricks": "Sluit Formbricks aan",
@@ -199,6 +200,7 @@
"disallow": "Niet toestaan",
"discard": "Weggooien",
"dismissed": "Afgewezen",
"distribute": "Distribueer",
"docs": "Documentatie",
"documentation": "Documentatie",
"domain": "Domein",
@@ -267,6 +269,7 @@
"logout": "Uitloggen",
"look_and_feel": "Kijk & voel",
"manage": "Beheren",
"mappings": "Koppelingen",
"marketing": "Marketing",
"member": "Lid",
"members": "Leden",
@@ -428,6 +431,7 @@
"top_right": "Rechtsboven",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unify": "Verenig",
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
"update": "Update",
"updated": "Bijgewerkt",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Dit onderzoek maakt gebruik van vertakkingslogica."
}
},
"unify": {
"add_feedback_source": "Feedbackbron toevoegen",
"add_source": "Bron toevoegen",
"are_you_sure": "Weet je het zeker?",
"automated": "Geautomatiseerd",
"aws_region": "AWS regio",
"change_file": "Bestand wijzigen",
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
"click_to_upload": "Klik om te uploaden",
"configure_import": "Import configureren",
"configure_mapping": "Koppeling configureren",
"connection": "Verbinding",
"connector_created_successfully": "Connector succesvol aangemaakt",
"connector_deleted_successfully": "Connector succesvol verwijderd",
"connector_updated_successfully": "Connector succesvol bijgewerkt",
"copied": "Gekopieerd!",
"copy": "Kopiëren",
"create_mapping": "Koppeling aanmaken",
"csv_at_least_one_row": "CSV moet minimaal één datarij bevatten.",
"csv_columns": "CSV kolommen",
"csv_empty_column_headers": "CSV bevat lege kolomkoppen. Alle kolommen moeten een naam hebben.",
"csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.",
"csv_files_only": "Alleen CSV bestanden",
"csv_import": "CSV import",
"csv_inconsistent_columns": "Rij {{row}} heeft inconsistente kolommen. Alle rijen moeten dezelfde koppen hebben.",
"csv_max_records": "Maximaal {{max}} records toegestaan.",
"default_connector_name_csv": "CSV import",
"default_connector_name_formbricks": "Formbricks Survey verbinding",
"delete_source": "Bron verwijderen",
"deselect_all": "Alles deselecteren",
"drop_a_field_here": "Zet hier een veld neer",
"drop_field_or": "Zet veld neer of",
"drop_zone_path": "Drop zone pad",
"edit_source_connection": "Bronverbinding bewerken",
"element_selected": "<strong>{count}</strong> element geselecteerd. Elke reactie op dit element zal een FeedbackRecord aanmaken in de Hub.",
"elements_selected": "<strong>{count}</strong> elementen geselecteerd. Elke reactie op deze elementen zal een FeedbackRecord aanmaken in de Hub.",
"enable_auto_sync": "Automatische synchronisatie inschakelen",
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"every_15_minutes": "Elke 15 minuten",
"every_30_minutes": "Elke 30 minuten",
"every_5_minutes": "Elke 5 minuten",
"every_hour": "Elk uur",
"feedback_date": "Feedbackdatum",
"field": "veld",
"fields": "velden",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Hub feedbackrecordvelden",
"iam_configuration_required": "IAM-configuratie vereist",
"iam_setup_instructions": "Voeg de Formbricks IAM-rol toe aan je S3 bucket policy om toegang mogelijk te maken.",
"import_csv_data": "CSV-gegevens importeren",
"load_sample_csv": "Voorbeeld-CSV laden",
"n_elements": "{count} elementen",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"no_surveys_found": "Geen enquêtes gevonden in deze omgeving",
"optional": "Optioneel",
"or": "of",
"or_drag_and_drop": "of sleep en zet neer",
"process_new_files_description": "Verwerk automatisch nieuwe bestanden die in de bucket worden geplaatst",
"processing_interval": "Verwerkingsinterval",
"region_ap_southeast_1": "Azië-Pacific (Singapore)",
"region_eu_central_1": "EU (Frankfurt)",
"region_eu_west_1": "EU (Ierland)",
"region_us_east_1": "VS Oost (N. Virginia)",
"region_us_west_2": "VS West (Oregon)",
"required": "Vereist",
"s3_bucket_description": "Plaats CSV-bestanden in je S3-bucket om automatisch feedback te importeren. Bestanden worden elke 15 minuten verwerkt.",
"s3_bucket_integration": "S3-bucket integratie",
"save_changes": "Wijzigingen opslaan",
"select_a_survey_to_see_elements": "Selecteer een enquête om de elementen te zien",
"select_a_value": "Selecteer een waarde...",
"select_all": "Selecteer alles",
"select_elements": "Selecteer elementen",
"select_questions": "Selecteer vragen",
"select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.",
"select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:",
"select_survey": "Selecteer enquête",
"select_survey_and_questions": "Selecteer enquête & vragen",
"select_survey_questions_description": "Kies welke enquêtevragen FeedbackRecords moeten aanmaken.",
"set_value": "waarde instellen",
"setup_connection": "Verbinding instellen",
"showing_rows": "3 van {count} rijen weergegeven",
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
"source_fields": "Bronvelden",
"source_name": "Bronnaam",
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
"sources": "Bronnen",
"status_active": "Actief",
"status_completed": "Voltooid",
"status_draft": "Voorlopige versie",
"status_error": "Fout",
"status_paused": "Gepauzeerd",
"survey_has_no_elements": "Deze enquête heeft geen vraagelementen",
"test_connection": "Verbinding testen",
"unify_feedback": "Feedback verenigen",
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
"upload_csv_data_description": "Upload een CSV-bestand of stel geautomatiseerde S3-imports in.",
"upload_csv_file": "CSV-bestand uploaden",
"view_setup_guide": "Bekijk installatiegids →",
"yes_delete": "Ja, verwijderen"
},
"workspace": {
"api_keys": {
"add_api_key": "API-sleutel toevoegen",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Pesquisa de App",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Certeza?",
"ask": "Perguntar",
"attributes": "atributos",
"back": "Voltar",
"billing": "Faturamento",
@@ -163,7 +164,7 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"completed": "Concluído",
"configuration": "Configuração",
"configure": "Configurar",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Não permita",
"discard": "Descartar",
"dismissed": "Dispensado",
"distribute": "Distribuir",
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
@@ -267,6 +269,7 @@
"logout": "Sair",
"look_and_feel": "Aparência e Experiência",
"manage": "gerenciar",
"mappings": "Mapeamentos",
"marketing": "marketing",
"member": "Membros",
"members": "Membros",
@@ -428,6 +431,7 @@
"top_right": "Canto Superior Direito",
"try_again": "Tenta de novo",
"type": "Tipo",
"unify": "Unificar",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Essa pesquisa usa lógica de ramificação."
}
},
"unify": {
"add_feedback_source": "Adicionar fonte de feedback",
"add_source": "Adicionar fonte",
"are_you_sure": "Certeza?",
"automated": "Automatizado",
"aws_region": "Região AWS",
"change_file": "Alterar arquivo",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para fazer upload",
"configure_import": "Configurar importação",
"configure_mapping": "Configurar mapeamento",
"connection": "Conexão",
"connector_created_successfully": "Conector criado com sucesso",
"connector_deleted_successfully": "Conector excluído com sucesso",
"connector_updated_successfully": "Conector atualizado com sucesso",
"copied": "Copiado!",
"copy": "Copiar",
"create_mapping": "Criar mapeamento",
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
"csv_columns": "Colunas CSV",
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
"csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas arquivos CSV",
"csv_import": "Importação CSV",
"csv_inconsistent_columns": "A linha {{row}} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
"csv_max_records": "Máximo de {{max}} registros permitidos.",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_source": "Excluir fonte",
"deselect_all": "Desmarcar tudo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"drop_zone_path": "Caminho da zona de soltar",
"edit_source_connection": "Editar conexão de origem",
"element_selected": "<strong>{count}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
"elements_selected": "<strong>{count}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
"enable_auto_sync": "Ativar sincronização automática",
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"every_15_minutes": "A cada 15 minutos",
"every_30_minutes": "A cada 30 minutos",
"every_5_minutes": "A cada 5 minutos",
"every_hour": "A cada hora",
"feedback_date": "Data do feedback",
"field": "campo",
"fields": "campos",
"formbricks_surveys": "Pesquisas Formbricks",
"hub_feedback_record_fields": "Campos de registro de feedback do Hub",
"iam_configuration_required": "Configuração IAM necessária",
"iam_setup_instructions": "Adicione a função IAM do Formbricks à política do seu bucket S3 para habilitar o acesso.",
"import_csv_data": "Importar dados CSV",
"load_sample_csv": "Carregar CSV de exemplo",
"n_elements": "{count} elementos",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente",
"optional": "Opcional",
"or": "ou",
"or_drag_and_drop": "ou arraste e solte",
"process_new_files_description": "Processar automaticamente novos arquivos adicionados ao bucket",
"processing_interval": "Intervalo de processamento",
"region_ap_southeast_1": "Ásia-Pacífico (Singapura)",
"region_eu_central_1": "UE (Frankfurt)",
"region_eu_west_1": "UE (Irlanda)",
"region_us_east_1": "Leste dos EUA (Norte da Virgínia)",
"region_us_west_2": "Oeste dos EUA (Oregon)",
"required": "Obrigatório",
"s3_bucket_description": "Adicione arquivos CSV ao seu bucket S3 para importar feedback automaticamente. Os arquivos são processados a cada 15 minutos.",
"s3_bucket_integration": "Integração com bucket S3",
"save_changes": "Salvar alterações",
"select_a_survey_to_see_elements": "Selecione uma pesquisa para ver seus elementos",
"select_a_value": "Selecione um valor...",
"select_all": "Selecionar tudo",
"select_elements": "Selecionar elementos",
"select_questions": "Selecionar perguntas",
"select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.",
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:",
"select_survey": "Selecionar pesquisa",
"select_survey_and_questions": "Selecionar pesquisa e perguntas",
"select_survey_questions_description": "Escolha quais perguntas da pesquisa devem criar FeedbackRecords.",
"set_value": "definir valor",
"setup_connection": "Configurar conexão",
"showing_rows": "Mostrando 3 de {count} linhas",
"source_connect_csv_description": "Importar feedback de arquivos CSV",
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
"source_fields": "Campos de origem",
"source_name": "Nome da origem",
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
"sources": "Origens",
"status_active": "Ativa",
"status_completed": "Concluído",
"status_draft": "Rascunho",
"status_error": "Erro",
"status_paused": "Pausado",
"survey_has_no_elements": "Esta pesquisa não possui elementos de pergunta",
"test_connection": "Testar conexão",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
"upload_csv_data_description": "Faça upload de um arquivo CSV ou configure importações automatizadas do S3.",
"upload_csv_file": "Fazer upload de arquivo CSV",
"view_setup_guide": "Ver guia de configuração →",
"yes_delete": "Sim, deletar"
},
"workspace": {
"api_keys": {
"add_api_key": "Adicionar chave de API",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Inquérito (app)",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?",
"ask": "Perguntar",
"attributes": "Atributos",
"back": "Voltar",
"billing": "Faturação",
@@ -163,7 +164,7 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"completed": "Concluído",
"configuration": "Configuração",
"configure": "Configurar",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Ligar Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Não permitir",
"discard": "Descartar",
"dismissed": "Dispensado",
"distribute": "Distribuir",
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
@@ -267,6 +269,7 @@
"logout": "Terminar sessão",
"look_and_feel": "Aparência e Sensação",
"manage": "Gerir",
"mappings": "Mapeamentos",
"marketing": "Marketing",
"member": "Membro",
"members": "Membros",
@@ -428,6 +431,7 @@
"top_right": "Superior Direito",
"try_again": "Tente novamente",
"type": "Tipo",
"unify": "Unificar",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Este questionário usa lógica de ramificação."
}
},
"unify": {
"add_feedback_source": "Adicionar fonte de feedback",
"add_source": "Adicionar fonte",
"are_you_sure": "Tem a certeza?",
"automated": "Automatizado",
"aws_region": "Região AWS",
"change_file": "Alterar ficheiro",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para carregar",
"configure_import": "Configurar importação",
"configure_mapping": "Configurar mapeamento",
"connection": "Conexão",
"connector_created_successfully": "Conector criado com sucesso",
"connector_deleted_successfully": "Conector eliminado com sucesso",
"connector_updated_successfully": "Conector atualizado com sucesso",
"copied": "Copiado!",
"copy": "Copiar",
"create_mapping": "Criar mapeamento",
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
"csv_columns": "Colunas CSV",
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
"csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas ficheiros CSV",
"csv_import": "Importação CSV",
"csv_inconsistent_columns": "A linha {{row}} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
"csv_max_records": "Máximo de {{max}} registos permitidos.",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_source": "Eliminar fonte",
"deselect_all": "Desselecionar tudo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"drop_zone_path": "Caminho da zona de soltar",
"edit_source_connection": "Editar ligação de origem",
"element_selected": "<strong>{count}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
"elements_selected": "<strong>{count}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
"enable_auto_sync": "Ativar sincronização automática",
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"every_15_minutes": "A cada 15 minutos",
"every_30_minutes": "A cada 30 minutos",
"every_5_minutes": "A cada 5 minutos",
"every_hour": "A cada hora",
"feedback_date": "Data do feedback",
"field": "campo",
"fields": "campos",
"formbricks_surveys": "Pesquisas Formbricks",
"hub_feedback_record_fields": "Campos de registo de feedback do Hub",
"iam_configuration_required": "Configuração IAM necessária",
"iam_setup_instructions": "Adiciona a função IAM do Formbricks à política do teu bucket S3 para ativar o acesso.",
"import_csv_data": "Importar dados CSV",
"load_sample_csv": "Carregar CSV de exemplo",
"n_elements": "{count} elementos",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"no_surveys_found": "Nenhum inquérito encontrado neste ambiente",
"optional": "Opcional",
"or": "ou",
"or_drag_and_drop": "ou arraste e largue",
"process_new_files_description": "Processar automaticamente novos ficheiros colocados no bucket",
"processing_interval": "Intervalo de processamento",
"region_ap_southeast_1": "Ásia-Pacífico (Singapura)",
"region_eu_central_1": "UE (Frankfurt)",
"region_eu_west_1": "UE (Irlanda)",
"region_us_east_1": "EUA Leste (N. Virgínia)",
"region_us_west_2": "EUA Oeste (Oregon)",
"required": "Obrigatório",
"s3_bucket_description": "Coloque ficheiros CSV no seu bucket S3 para importar automaticamente feedback. Os ficheiros são processados a cada 15 minutos.",
"s3_bucket_integration": "Integração com bucket S3",
"save_changes": "Guardar alterações",
"select_a_survey_to_see_elements": "Selecione um inquérito para ver os seus elementos",
"select_a_value": "Selecione um valor...",
"select_all": "Selecionar tudo",
"select_elements": "Selecionar elementos",
"select_questions": "Selecionar perguntas",
"select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.",
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:",
"select_survey": "Selecionar inquérito",
"select_survey_and_questions": "Selecionar inquérito e perguntas",
"select_survey_questions_description": "Escolha quais perguntas do inquérito devem criar FeedbackRecords.",
"set_value": "definir valor",
"setup_connection": "Configurar ligação",
"showing_rows": "A mostrar 3 de {count} linhas",
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
"source_fields": "Campos da fonte",
"source_name": "Nome da fonte",
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
"sources": "Fontes",
"status_active": "Ativa",
"status_completed": "Concluído",
"status_draft": "Rascunho",
"status_error": "Erro",
"status_paused": "Em pausa",
"survey_has_no_elements": "Este inquérito não tem elementos de pergunta",
"test_connection": "Testar ligação",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
"upload_csv_data_description": "Carrega um ficheiro CSV ou configura importações automáticas do S3.",
"upload_csv_file": "Carregar ficheiro CSV",
"view_setup_guide": "Ver guia de configuração →",
"yes_delete": "Sim, eliminar"
},
"workspace": {
"api_keys": {
"add_api_key": "Adicionar chave API",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Sondaj aplicație",
"apply_filters": "Aplică filtre",
"are_you_sure": "Ești sigur?",
"ask": "Întreabă",
"attributes": "Atribute",
"back": "Înapoi",
"billing": "Facturare",
@@ -163,7 +164,7 @@
"code": "Cod",
"collapse_rows": "Restrânge rânduri",
"completed": "Completat",
"configuration": "Configurare",
"configure": "Configurează",
"confirm": "Confirmare",
"connect": "Conectează",
"connect_formbricks": "Conectează Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Nu permite",
"discard": "Renunță",
"dismissed": "Respins",
"distribute": "Distribuie",
"docs": "Documentație",
"documentation": "Documentație",
"domain": "Domeniu",
@@ -267,6 +269,7 @@
"logout": "Deconectare",
"look_and_feel": "Aspect și Comportament",
"manage": "Gestionați",
"mappings": "Mapări",
"marketing": "Marketing",
"member": "Membru",
"members": "Membri",
@@ -428,6 +431,7 @@
"top_right": "Dreapta Sus",
"try_again": "Încearcă din nou",
"type": "Tip",
"unify": "Unifică",
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
"update": "Actualizare",
"updated": "Actualizat",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Acest sondaj folosește logică de ramificare."
}
},
"unify": {
"add_feedback_source": "Adaugă sursă de feedback",
"add_source": "Adaugă sursă",
"are_you_sure": "Ești sigur?",
"automated": "Automatizat",
"aws_region": "Regiune AWS",
"change_file": "Schimbă fișierul",
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
"click_to_upload": "Apasă pentru a încărca",
"configure_import": "Configurează importul",
"configure_mapping": "Configurează maparea",
"connection": "Conexiune",
"connector_created_successfully": "Conector creat cu succes",
"connector_deleted_successfully": "Conector șters cu succes",
"connector_updated_successfully": "Conector actualizat cu succes",
"copied": "Copiat!",
"copy": "Copiază",
"create_mapping": "Creează mapare",
"csv_at_least_one_row": "CSV-ul trebuie să conțină cel puțin un rând de date.",
"csv_columns": "Coloane CSV",
"csv_empty_column_headers": "CSV-ul conține antete de coloană goale. Toate coloanele trebuie să aibă un nume.",
"csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.",
"csv_files_only": "Doar fișiere CSV",
"csv_import": "Import CSV",
"csv_inconsistent_columns": "Rândul {{row}} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.",
"csv_max_records": "Sunt permise maximum {{max}} înregistrări.",
"default_connector_name_csv": "Import CSV",
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
"delete_source": "Șterge sursa",
"deselect_all": "Deselectează tot",
"drop_a_field_here": "Trage un câmp aici",
"drop_field_or": "Trage câmpul sau",
"drop_zone_path": "Cale zonă de plasare",
"edit_source_connection": "Editează conexiunea sursei",
"element_selected": "<strong>{count}</strong> element selectat. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
"elements_selected": "<strong>{count}</strong> elemente selectate. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
"enable_auto_sync": "Activează auto-sync",
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"every_15_minutes": "La fiecare 15 minute",
"every_30_minutes": "La fiecare 30 de minute",
"every_5_minutes": "La fiecare 5 minute",
"every_hour": "La fiecare oră",
"feedback_date": "Data feedbackului",
"field": "câmp",
"fields": "câmpuri",
"formbricks_surveys": "Chestionare Formbricks",
"hub_feedback_record_fields": "Câmpuri FeedbackRecord din Hub",
"iam_configuration_required": "Configurare IAM necesară",
"iam_setup_instructions": "Adaugă rolul Formbricks IAM în politica bucket-ului tău S3 pentru a permite accesul.",
"import_csv_data": "Importă date CSV",
"load_sample_csv": "Încarcă un CSV de exemplu",
"n_elements": "{count} elemente",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"no_surveys_found": "Nu s-au găsit sondaje în acest mediu",
"optional": "Opțional",
"or": "sau",
"or_drag_and_drop": "sau trage și lasă aici",
"process_new_files_description": "Procesează automat fișierele noi adăugate în bucket",
"processing_interval": "Interval de procesare",
"region_ap_southeast_1": "Asia Pacific (Singapore)",
"region_eu_central_1": "UE (Frankfurt)",
"region_eu_west_1": "UE (Irlanda)",
"region_us_east_1": "SUA Est (N. Virginia)",
"region_us_west_2": "SUA Vest (Oregon)",
"required": "Obligatoriu",
"s3_bucket_description": "Adaugă fișiere CSV în bucket-ul tău S3 pentru a importa automat feedback-ul. Fișierele sunt procesate la fiecare 15 minute.",
"s3_bucket_integration": "Integrare S3 Bucket",
"save_changes": "Salvează modificările",
"select_a_survey_to_see_elements": "Selectează un sondaj pentru a vedea elementele",
"select_a_value": "Selectează o valoare...",
"select_all": "Selectează tot",
"select_elements": "Selectează elemente",
"select_questions": "Selectează întrebări",
"select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.",
"select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:",
"select_survey": "Selectează chestionar",
"select_survey_and_questions": "Selectează chestionar și întrebări",
"select_survey_questions_description": "Alege ce întrebări din chestionar vor crea FeedbackRecords.",
"set_value": "setează valoare",
"setup_connection": "Configurează conexiunea",
"showing_rows": "Se afișează 3 din {count} rânduri",
"source_connect_csv_description": "Importă feedback din fișiere CSV",
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
"source_fields": "Câmpuri sursă",
"source_name": "Nume sursă",
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
"sources": "Surse",
"status_active": "Activ",
"status_completed": "Finalizat",
"status_draft": "Schiță",
"status_error": "Eroare",
"status_paused": "Pauzat",
"survey_has_no_elements": "Acest chestionar nu are elemente de întrebare",
"test_connection": "Testează conexiunea",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
"upload_csv_data_description": "Încarcă un fișier CSV sau configurează importuri automate din S3.",
"upload_csv_file": "Încarcă fișier CSV",
"view_setup_guide": "Vezi ghidul de configurare →",
"yes_delete": "Da, șterge"
},
"workspace": {
"api_keys": {
"add_api_key": "Adaugă cheie API",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "Опрос о приложении",
"apply_filters": "Применить фильтры",
"are_you_sure": "Вы уверены?",
"ask": "Спросить",
"attributes": "Атрибуты",
"back": "Назад",
"billing": "Оплата",
@@ -163,7 +164,7 @@
"code": "Код",
"collapse_rows": "Свернуть строки",
"completed": "Завершено",
"configuration": "Конфигурация",
"configure": "Настроить",
"confirm": "Подтвердить",
"connect": "Подключить",
"connect_formbricks": "Подключить Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Не разрешать",
"discard": "Отменить",
"dismissed": "Отклонено",
"distribute": "Распределить",
"docs": "Документация",
"documentation": "Документация",
"domain": "Домен",
@@ -267,6 +269,7 @@
"logout": "Выйти",
"look_and_feel": "Внешний вид",
"manage": "Управление",
"mappings": "Сопоставления",
"marketing": "Маркетинг",
"member": "Участник",
"members": "Участники",
@@ -428,6 +431,7 @@
"top_right": "Вверху справа",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
"unify": "Объединить",
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
"update": "Обновить",
"updated": "Обновлено",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "В этом опросе используется разветвлённая логика."
}
},
"unify": {
"add_feedback_source": "Добавить источник отзывов",
"add_source": "Добавить источник",
"are_you_sure": "Вы уверены?",
"automated": "Автоматически",
"aws_region": "Регион AWS",
"change_file": "Изменить файл",
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
"click_to_upload": "Кликните для загрузки",
"configure_import": "Настроить импорт",
"configure_mapping": "Настроить сопоставление",
"connection": "Подключение",
"connector_created_successfully": "Коннектор успешно создан",
"connector_deleted_successfully": "Коннектор успешно удалён",
"connector_updated_successfully": "Коннектор успешно обновлён",
"copied": "Скопировано!",
"copy": "Копировать",
"create_mapping": "Создать сопоставление",
"csv_at_least_one_row": "CSV должен содержать хотя бы одну строку с данными.",
"csv_columns": "Столбцы CSV",
"csv_empty_column_headers": "В CSV есть пустые заголовки столбцов. У всех столбцов должно быть имя.",
"csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.",
"csv_files_only": "Только файлы CSV",
"csv_import": "Импорт CSV",
"csv_inconsistent_columns": "В строке {{row}} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
"csv_max_records": "Допустимо не более {{max}} записей.",
"default_connector_name_csv": "Импорт CSV",
"default_connector_name_formbricks": "Подключение опроса Formbricks",
"delete_source": "Удалить источник",
"deselect_all": "Снять выделение со всех",
"drop_a_field_here": "Перетащи сюда поле",
"drop_field_or": "Перетащи поле или",
"drop_zone_path": "Путь зоны сброса",
"edit_source_connection": "Редактировать подключение источника",
"element_selected": "<strong>{count}</strong> элемент выбран. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
"elements_selected": "<strong>{count}</strong> элементов выбрано. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
"enable_auto_sync": "Включить авто-синхронизацию",
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"every_15_minutes": "Каждые 15 минут",
"every_30_minutes": "Каждые 30 минут",
"every_5_minutes": "Каждые 5 минут",
"every_hour": "Каждый час",
"feedback_date": "Дата отзыва",
"field": "поле",
"fields": "поля",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Поля FeedbackRecord в Hub",
"iam_configuration_required": "Требуется настройка IAM",
"iam_setup_instructions": "Добавь роль Formbricks IAM в политику своего S3-бакета для предоставления доступа.",
"import_csv_data": "Импортировать данные CSV",
"load_sample_csv": "Загрузить пример CSV",
"n_elements": "{count} элементов",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"no_surveys_found": "В этой среде не найдено опросов",
"optional": "Необязательно",
"or": "или",
"or_drag_and_drop": "или перетащите файл",
"process_new_files_description": "Автоматически обрабатывать новые файлы, добавленные в бакет",
"processing_interval": "Интервал обработки",
"region_ap_southeast_1": "Азиатско-Тихоокеанский регион (Сингапур)",
"region_eu_central_1": "ЕС (Франкфурт)",
"region_eu_west_1": "ЕС (Ирландия)",
"region_us_east_1": "США Восток (Северная Вирджиния)",
"region_us_west_2": "США Запад (Орегон)",
"required": "Обязательно",
"s3_bucket_description": "Перемещайте файлы CSV в свой S3-бакет для автоматического импорта отзывов. Файлы обрабатываются каждые 15 минут.",
"s3_bucket_integration": "Интеграция с S3-бакетом",
"save_changes": "Сохранить изменения",
"select_a_survey_to_see_elements": "Выберите опрос, чтобы увидеть его элементы",
"select_a_value": "Выберите значение...",
"select_all": "Выбрать все",
"select_elements": "Выбрать элементы",
"select_questions": "Выбрать вопросы",
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
"select_survey": "Выбрать опрос",
"select_survey_and_questions": "Выбрать опрос и вопросы",
"select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.",
"set_value": "установить значение",
"setup_connection": "Настроить подключение",
"showing_rows": "Показано 3 из {count} строк",
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
"source_fields": "Поля источника",
"source_name": "Имя источника",
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
"sources": "Источники",
"status_active": "Активен",
"status_completed": "Завершён",
"status_draft": "Черновик",
"status_error": "Ошибка",
"status_paused": "Приостановлен",
"survey_has_no_elements": "В этом опросе нет вопросов",
"test_connection": "Проверить подключение",
"unify_feedback": "Обратная связь Unify",
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
"upload_csv_data_description": "Загрузи CSV-файл или настрой автоматический импорт из S3.",
"upload_csv_file": "Загрузить CSV-файл",
"view_setup_guide": "Посмотреть инструкцию по настройке →",
"yes_delete": "Да, удалить"
},
"workspace": {
"api_keys": {
"add_api_key": "Добавить API-ключ",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "App-enkät",
"apply_filters": "Tillämpa filter",
"are_you_sure": "Är du säker?",
"ask": "Fråga",
"attributes": "Attribut",
"back": "Tillbaka",
"billing": "Fakturering",
@@ -163,7 +164,7 @@
"code": "Kod",
"collapse_rows": "Dölj rader",
"completed": "Slutförd",
"configuration": "Konfiguration",
"configure": "Konfigurera",
"confirm": "Bekräfta",
"connect": "Anslut",
"connect_formbricks": "Anslut Formbricks",
@@ -199,6 +200,7 @@
"disallow": "Tillåt inte",
"discard": "Förkasta",
"dismissed": "Avvisad",
"distribute": "Dela ut",
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domän",
@@ -267,6 +269,7 @@
"logout": "Logga ut",
"look_and_feel": "Utseende",
"manage": "Hantera",
"mappings": "Mappningar",
"marketing": "Marknadsföring",
"member": "Medlem",
"members": "Medlemmar",
@@ -428,6 +431,7 @@
"top_right": "Övre höger",
"try_again": "Försök igen",
"type": "Typ",
"unify": "Förenas",
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
"update": "Uppdatera",
"updated": "Uppdaterad",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "Denna enkät använder förgreningslogik."
}
},
"unify": {
"add_feedback_source": "Lägg till feedbackkälla",
"add_source": "Lägg till källa",
"are_you_sure": "Är du säker?",
"automated": "Automatiserad",
"aws_region": "AWS-region",
"change_file": "Byt fil",
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
"click_to_upload": "Klicka för att ladda upp",
"configure_import": "Konfigurera import",
"configure_mapping": "Konfigurera mappning",
"connection": "Anslutning",
"connector_created_successfully": "Kopplingen skapades",
"connector_deleted_successfully": "Kopplingen togs bort",
"connector_updated_successfully": "Kopplingen uppdaterades",
"copied": "Kopierat!",
"copy": "Kopiera",
"create_mapping": "Skapa mappning",
"csv_at_least_one_row": "CSV-filen måste innehålla minst en datarad.",
"csv_columns": "CSV-kolumner",
"csv_empty_column_headers": "CSV-filen innehåller tomma kolumnrubriker. Alla kolumner måste ha ett namn.",
"csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.",
"csv_files_only": "Endast CSV-filer",
"csv_import": "CSV-import",
"csv_inconsistent_columns": "Rad {{row}} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.",
"csv_max_records": "Maximalt {{max}} poster tillåtna.",
"default_connector_name_csv": "CSV-import",
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
"delete_source": "Ta bort källa",
"deselect_all": "Avmarkera alla",
"drop_a_field_here": "Släpp ett fält här",
"drop_field_or": "Släpp fält eller",
"drop_zone_path": "Släppzonens sökväg",
"edit_source_connection": "Redigera källans anslutning",
"element_selected": "<strong>{count}</strong> element vald. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
"elements_selected": "<strong>{count}</strong> element valda. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
"enable_auto_sync": "Aktivera auto-sync",
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"every_15_minutes": "Var 15:e minut",
"every_30_minutes": "Var 30:e minut",
"every_5_minutes": "Var 5:e minut",
"every_hour": "Varje timme",
"feedback_date": "Feedbackdatum",
"field": "fält",
"fields": "fält",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Fält för Hub Feedback Record",
"iam_configuration_required": "IAM-konfiguration krävs",
"iam_setup_instructions": "Lägg till Formbricks IAM-roll i din S3-bucketpolicy för att aktivera åtkomst.",
"import_csv_data": "Importera CSV-data",
"load_sample_csv": "Ladda exempel-CSV",
"n_elements": "{count} element",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"no_surveys_found": "Inga enkäter hittades i denna miljö",
"optional": "Valfritt",
"or": "eller",
"or_drag_and_drop": "eller dra och släpp",
"process_new_files_description": "Bearbeta nya filer som släpps i bucketen automatiskt",
"processing_interval": "Bearbetningsintervall",
"region_ap_southeast_1": "Asien och Stillahavsområdet (Singapore)",
"region_eu_central_1": "EU (Frankfurt)",
"region_eu_west_1": "EU (Irland)",
"region_us_east_1": "USA Öst (N. Virginia)",
"region_us_west_2": "USA Väst (Oregon)",
"required": "Obligatoriskt",
"s3_bucket_description": "Släpp CSV-filer i din S3-bucket för att automatiskt importera feedback. Filer bearbetas var 15:e minut.",
"s3_bucket_integration": "S3-bucket-integration",
"save_changes": "Spara ändringar",
"select_a_survey_to_see_elements": "Välj en enkät för att se dess element",
"select_a_value": "Välj ett värde...",
"select_all": "Välj alla",
"select_elements": "Välj element",
"select_questions": "Välj frågor",
"select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.",
"select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:",
"select_survey": "Välj enkät",
"select_survey_and_questions": "Välj enkät & frågor",
"select_survey_questions_description": "Välj vilka enkätfrågor som ska skapa FeedbackRecords.",
"set_value": "ange värde",
"setup_connection": "Ställ in anslutning",
"showing_rows": "Visar 3 av {count} rader",
"source_connect_csv_description": "Importera feedback från CSV-filer",
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
"source_fields": "Källfält",
"source_name": "Källnamn",
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
"sources": "Källor",
"status_active": "Aktiv",
"status_completed": "Slutförd",
"status_draft": "Utkast",
"status_error": "Fel",
"status_paused": "Pausad",
"survey_has_no_elements": "Den här enkäten har inga frågeelement",
"test_connection": "Testa anslutning",
"unify_feedback": "Samla feedback",
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
"upload_csv_data_description": "Ladda upp en CSV-fil eller ställ in automatiska S3-importer.",
"upload_csv_file": "Ladda upp CSV-fil",
"view_setup_guide": "Visa installationsguide →",
"yes_delete": "Ja, ta bort"
},
"workspace": {
"api_keys": {
"add_api_key": "Lägg till API-nyckel",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "应用 程序 调查",
"apply_filters": "应用 筛选",
"are_you_sure": "你 确定 吗?",
"ask": "提问",
"attributes": "属性",
"back": "返回",
"billing": "账单",
@@ -163,7 +164,7 @@
"code": "代码",
"collapse_rows": "折叠 行",
"completed": "完成",
"configuration": "配置",
"configure": "配置",
"confirm": "确认",
"connect": "连接",
"connect_formbricks": "连接 Formbricks",
@@ -199,6 +200,7 @@
"disallow": "不允许",
"discard": "丢弃",
"dismissed": "忽略",
"distribute": "分发",
"docs": "文档",
"documentation": "文档",
"domain": "域名",
@@ -267,6 +269,7 @@
"logout": "退出登录",
"look_and_feel": "外观 & 感觉",
"manage": "管理",
"mappings": "映射",
"marketing": "市场营销",
"member": "成员",
"members": "成员",
@@ -428,6 +431,7 @@
"top_right": "右上",
"try_again": "再试一次",
"type": "类型",
"unify": "统一",
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
"update": "更新",
"updated": "已更新",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "此调查 使用 分支逻辑。"
}
},
"unify": {
"add_feedback_source": "添加反馈来源",
"add_source": "添加来源",
"are_you_sure": "你确定吗?",
"automated": "自动化",
"aws_region": "AWS 区域",
"change_file": "更换文件",
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
"click_to_upload": "点击上传",
"configure_import": "配置导入",
"configure_mapping": "配置映射",
"connection": "连接",
"connector_created_successfully": "连接器创建成功",
"connector_deleted_successfully": "连接器删除成功",
"connector_updated_successfully": "连接器更新成功",
"copied": "已复制!",
"copy": "复制",
"create_mapping": "创建映射",
"csv_at_least_one_row": "CSV 文件中至少要有一行数据。",
"csv_columns": "CSV 列",
"csv_empty_column_headers": "CSV 文件包含空的列标题。所有列都必须有名称。",
"csv_file_too_large": "CSV 文件过大,最大支持 2MB。",
"csv_files_only": "仅限 CSV 文件",
"csv_import": "CSV 导入",
"csv_inconsistent_columns": "第 {{row}} 行的列数不一致。所有行必须有相同的标题。",
"csv_max_records": "最多允许 {{max}} 条记录。",
"default_connector_name_csv": "CSV 导入",
"default_connector_name_formbricks": "Formbricks 调查连接",
"delete_source": "删除来源",
"deselect_all": "取消全选",
"drop_a_field_here": "将字段拖到这里",
"drop_field_or": "拖放字段或",
"drop_zone_path": "拖放区域路径",
"edit_source_connection": "编辑源连接",
"element_selected": "已选择 <strong>{count}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
"elements_selected": "已选择 <strong>{count}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
"enable_auto_sync": "启用 auto 同步",
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"every_15_minutes": "每 15 分钟",
"every_30_minutes": "每 30 分钟",
"every_5_minutes": "每 5 分钟",
"every_hour": "每小时",
"feedback_date": "反馈日期",
"field": "字段",
"fields": "字段",
"formbricks_surveys": "Formbricks Surveys",
"hub_feedback_record_fields": "Hub 反馈记录字段",
"iam_configuration_required": "需要 IAM 配置",
"iam_setup_instructions": "将 Formbricks IAM 角色添加到你的 S3 bucket 策略中以启用访问权限。",
"import_csv_data": "导入 CSV 数据",
"load_sample_csv": "加载示例 CSV",
"n_elements": "{count} 个元素",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"no_surveys_found": "此环境下未找到调查",
"optional": "可选",
"or": "或",
"or_drag_and_drop": "或拖放",
"process_new_files_description": "自动处理存储桶中新上传的文件",
"processing_interval": "处理间隔",
"region_ap_southeast_1": "亚太地区(新加坡)",
"region_eu_central_1": "欧盟(法兰克福)",
"region_eu_west_1": "欧盟(爱尔兰)",
"region_us_east_1": "美国东部(弗吉尼亚北部)",
"region_us_west_2": "美国西部(俄勒冈)",
"required": "必填",
"s3_bucket_description": "将 CSV 文件放入你的 S3 存储桶,即可自动导入反馈。文件每 15 分钟处理一次。",
"s3_bucket_integration": "S3 存储桶集成",
"save_changes": "保存更改",
"select_a_survey_to_see_elements": "选择一个调查以查看其元素",
"select_a_value": "选择一个值...",
"select_all": "全选",
"select_elements": "选择元素",
"select_questions": "选择问题",
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
"select_survey": "选择调查",
"select_survey_and_questions": "选择调查和问题",
"select_survey_questions_description": "选择哪些调查问题会创建反馈记录。",
"set_value": "设置值",
"setup_connection": "设置连接",
"showing_rows": "显示 {count} 行中的 3 行",
"source_connect_csv_description": "从 CSV 文件导入反馈",
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
"source_fields": "来源字段",
"source_name": "来源名称",
"source_type_cannot_be_changed": "来源类型无法更改",
"sources": "来源",
"status_active": "已激活",
"status_completed": "已完成",
"status_draft": "草稿",
"status_error": "错误",
"status_paused": "已暂停",
"survey_has_no_elements": "此调查没有问题元素",
"test_connection": "测试连接",
"unify_feedback": "统一反馈",
"update_mapping_description": "更新此来源的映射配置。",
"upload_csv_data_description": "上传 CSV 文件或设置自动 S3 导入。",
"upload_csv_file": "上传 CSV 文件",
"view_setup_guide": "查看设置指南 →",
"yes_delete": "是的,删除"
},
"workspace": {
"api_keys": {
"add_api_key": "添加 API 密钥",
+109 -1
View File
@@ -141,6 +141,7 @@
"app_survey": "應用程式問卷",
"apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?",
"ask": "提問",
"attributes": "屬性",
"back": "返回",
"billing": "帳單",
@@ -163,7 +164,7 @@
"code": "程式碼",
"collapse_rows": "摺疊列",
"completed": "已完成",
"configuration": "組態",
"configure": "設定",
"confirm": "確認",
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
@@ -199,6 +200,7 @@
"disallow": "不允許",
"discard": "捨棄",
"dismissed": "已關閉",
"distribute": "分發",
"docs": "文件",
"documentation": "文件",
"domain": "網域",
@@ -267,6 +269,7 @@
"logout": "登出",
"look_and_feel": "外觀與風格",
"manage": "管理",
"mappings": "對應關係",
"marketing": "行銷",
"member": "成員",
"members": "成員",
@@ -428,6 +431,7 @@
"top_right": "右上",
"try_again": "再試一次",
"type": "類型",
"unify": "統整",
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
"update": "更新",
"updated": "已更新",
@@ -2035,6 +2039,110 @@
"uses_branching_logic": "此問卷使用分支邏輯。"
}
},
"unify": {
"add_feedback_source": "新增回饋來源",
"add_source": "新增來源",
"are_you_sure": "您確定嗎?",
"automated": "自動化",
"aws_region": "AWS 區域",
"change_file": "更換檔案",
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
"click_to_upload": "點擊以上傳",
"configure_import": "設定匯入",
"configure_mapping": "設定對應關係",
"connection": "連線",
"connector_created_successfully": "連接器建立成功",
"connector_deleted_successfully": "連接器刪除成功",
"connector_updated_successfully": "連接器更新成功",
"copied": "已複製!",
"copy": "複製",
"create_mapping": "建立對應關係",
"csv_at_least_one_row": "CSV 必須至少包含一筆資料列。",
"csv_columns": "CSV 欄位",
"csv_empty_column_headers": "CSV 包含空白的欄位標題。所有欄位都必須有名稱。",
"csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。",
"csv_files_only": "僅限 CSV 檔案",
"csv_import": "CSV 匯入",
"csv_inconsistent_columns": "第 {{row}} 列的欄位數不一致。所有資料列必須有相同的標題。",
"csv_max_records": "最多允許 {{max}} 筆紀錄。",
"default_connector_name_csv": "CSV 匯入",
"default_connector_name_formbricks": "Formbricks 問卷連線",
"delete_source": "刪除來源",
"deselect_all": "取消全選",
"drop_a_field_here": "請將欄位拖曳到這裡",
"drop_field_or": "拖曳欄位或",
"drop_zone_path": "拖曳區路徑",
"edit_source_connection": "編輯來源連線",
"element_selected": "已選取 <strong>{count}</strong> 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。",
"elements_selected": "已選取 <strong>{count}</strong> 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。",
"enable_auto_sync": "啟用 auto 同步",
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"every_15_minutes": "每 15 分鐘",
"every_30_minutes": "每 30 分鐘",
"every_5_minutes": "每 5 分鐘",
"every_hour": "每小時",
"feedback_date": "回饋日期",
"field": "欄位",
"fields": "欄位",
"formbricks_surveys": "Formbricks 問卷",
"hub_feedback_record_fields": "Hub 回饋紀錄欄位",
"iam_configuration_required": "需要 IAM 設定",
"iam_setup_instructions": "請將 Formbricks IAM 角色加入你的 S3 bucket policy 以啟用存取權限。",
"import_csv_data": "匯入 CSV 資料",
"load_sample_csv": "載入範例 CSV",
"n_elements": "{count} 個元素",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"no_surveys_found": "此環境中找不到問卷",
"optional": "選填",
"or": "或",
"or_drag_and_drop": "或拖曳檔案",
"process_new_files_description": "自動處理丟到 bucket 裡的新檔案",
"processing_interval": "處理間隔",
"region_ap_southeast_1": "亞太區(新加坡)",
"region_eu_central_1": "歐盟(法蘭克福)",
"region_eu_west_1": "歐盟(愛爾蘭)",
"region_us_east_1": "美國東部(維吉尼亞北部)",
"region_us_west_2": "美國西部(奧勒岡)",
"required": "必填",
"s3_bucket_description": "將 CSV 檔案放到你的 S3 bucket,就能自動匯入回饋。檔案每 15 分鐘處理一次。",
"s3_bucket_integration": "S3 Bucket 整合",
"save_changes": "儲存變更",
"select_a_survey_to_see_elements": "請選擇問卷以查看其元素",
"select_a_value": "請選擇一個值...",
"select_all": "全選",
"select_elements": "選取元素",
"select_questions": "選取問題",
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
"select_survey": "選擇問卷",
"select_survey_and_questions": "選擇問卷與問題",
"select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。",
"set_value": "設定值",
"setup_connection": "設定連線",
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
"source_fields": "來源欄位",
"source_name": "來源名稱",
"source_type_cannot_be_changed": "來源類型無法變更",
"sources": "來源",
"status_active": "啟用中",
"status_completed": "已完成",
"status_draft": "草稿",
"status_error": "錯誤",
"status_paused": "已暫停",
"survey_has_no_elements": "此問卷沒有任何問題",
"test_connection": "測試連線",
"unify_feedback": "整合回饋",
"update_mapping_description": "更新此來源的對應設定。",
"upload_csv_data_description": "上傳 CSV 檔案或設定自動 S3 匯入。",
"upload_csv_file": "上傳 CSV 檔案",
"view_setup_guide": "查看設定指南 →",
"yes_delete": "確定刪除"
},
"workspace": {
"api_keys": {
"add_api_key": "新增 API 金鑰",
@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
@{part}
</span>
);
@@ -46,7 +46,7 @@ export const generateAttributeTableColumns = (
cell: ({ row }) => {
const description = row.original.description;
return description ? (
<div className={isExpanded ? "break-words whitespace-normal" : "truncate"}>
<div className={isExpanded ? "whitespace-normal break-words" : "truncate"}>
<HighlightedText value={description} searchValue={searchValue} />
</div>
) : (
@@ -132,7 +132,7 @@ export const UploadContactsAttributes = ({
return (
<>
<span className="overflow-hidden font-medium text-ellipsis text-slate-700">{csvColumn}</span>
<span className="overflow-hidden text-ellipsis font-medium text-slate-700">{csvColumn}</span>
<div className="flex items-center gap-2">
<UploadContactsAttributeCombobox
open={open}
@@ -176,7 +176,7 @@ export function TargetingCard({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
strokeWidth={3}
@@ -249,7 +249,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm text-slate-500 italic">
<p className="text-sm italic text-slate-500">
{t("environments.workspace.languages.no_language_found")}
</p>
)}
@@ -151,7 +151,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm text-slate-700 capitalize">{actionClass.type}</p>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
</div>
</div>
<div className="">
@@ -1,6 +1,14 @@
"use client";
import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import {
BlocksIcon,
BrushIcon,
Cable,
LanguagesIcon,
ListChecksIcon,
TagIcon,
UsersIcon,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
@@ -69,6 +77,13 @@ export const ProjectConfigNavigation = ({
href: `/environments/${environmentId}/workspace/tags`,
current: pathname?.includes("/tags"),
},
{
id: "unify",
label: t("environments.unify.unify_feedback"),
icon: <Cable className="h-5 w-5" />,
href: `/environments/${environmentId}/workspace/unify`,
current: pathname?.includes("/unify"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -185,7 +185,7 @@ export const EndScreenForm = ({
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -158,7 +158,7 @@ export const HiddenFieldsCard = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<EyeOff className="h-4 w-4" />
</div>
@@ -191,7 +191,7 @@ export const HiddenFieldsCard = ({
);
})
) : (
<p className="mt-2 text-sm text-slate-500 italic">
<p className="mt-2 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
@@ -475,7 +475,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && (
<div>
@@ -68,7 +68,7 @@ export const DataTableHeader = <T,>({
onTouchStart={header.getResizeHandler()}
data-testid="column-resize-handle"
className={cn(
"absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
"absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
!header.column.getCanResize() ? "hidden" : "group-hover:block"
)}></button>
@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
tabIndex={0}
aria-controls="options"
aria-expanded={open}
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
className={cn("flex h-10 w-full cursor-pointer items-center justify-end bg-white pr-2", {
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
"pointer-events-none": isClearing,
})}>
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>
@@ -62,7 +62,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
{...props}
/>
));
@@ -75,7 +75,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
@@ -61,7 +61,7 @@ export const StylingTabs = <T extends string | number>({
className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm",
selectedOption === option.value ? "bg-slate-100" : "bg-white",
"focus:ring-brand-dark focus:ring-opacity-50 focus:ring-2 focus:outline-none",
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50",
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
)}>
<input
+4 -4
View File
@@ -54,9 +54,9 @@
"@opentelemetry/sdk-node": "0.211.0",
"@opentelemetry/sdk-trace-base": "2.5.0",
"@opentelemetry/semantic-conventions": "1.38.0",
"@prisma/instrumentation": "6.14.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"@prisma/instrumentation": "6.14.0",
"@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-collapsible": "1.1.10",
@@ -114,10 +114,12 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-i18next": "15.7.3",
@@ -136,9 +138,7 @@
"webpack": "5.99.8",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
+3 -3
View File
@@ -40,7 +40,7 @@ test.describe("Survey Styling", async () => {
await user.login();
// Navigate to Look & Feel settings
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Configure" }).click();
await page.getByRole("link", { name: "Look & Feel" }).click();
await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/);
@@ -171,7 +171,7 @@ test.describe("Survey Styling", async () => {
await user.login();
// Navigate to Look & Feel settings
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Configure" }).click();
await page.getByRole("link", { name: "Look & Feel" }).click();
await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/);
@@ -260,7 +260,7 @@ test.describe("Survey Styling", async () => {
await user.login();
// Navigate to Look & Feel settings
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Configure" }).click();
await page.getByRole("link", { name: "Look & Feel" }).click();
await page.waitForURL(/\/environments\/[^/]+\/workspace\/look/);
+2 -2
View File
@@ -248,7 +248,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
//add a new language
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Configure" }).click();
await page.getByRole("link", { name: "Survey Languages" }).click();
await page.getByRole("button", { name: "Edit languages" }).click();
await page.getByRole("button", { name: "Add language" }).click();
@@ -265,7 +265,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("German", { exact: true }).nth(1).click();
await page.getByRole("button", { name: "Save changes" }).click();
await page.waitForTimeout(2000);
await page.getByRole("link", { name: "Surveys" }).click();
await page.getByRole("link", { name: "Ask" }).click();
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
@@ -0,0 +1,76 @@
-- CreateEnum
CREATE TYPE "public"."ConnectorType" AS ENUM ('formbricks', 'csv');
-- CreateEnum
CREATE TYPE "public"."ConnectorStatus" AS ENUM ('active', 'paused', 'error');
-- CreateEnum
CREATE TYPE "public"."HubFieldType" AS ENUM ('text', 'categorical', 'nps', 'csat', 'ces', 'rating', 'number', 'boolean', 'date');
-- CreateTable
CREATE TABLE "public"."Connector" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."ConnectorType" NOT NULL,
"status" "public"."ConnectorStatus" NOT NULL DEFAULT 'active',
"environmentId" TEXT NOT NULL,
"last_sync_at" TIMESTAMP(3),
"error_message" TEXT,
CONSTRAINT "Connector_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ConnectorFormbricksMapping" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"connectorId" TEXT NOT NULL,
"environmentId" TEXT NOT NULL,
"surveyId" TEXT NOT NULL,
"elementId" TEXT NOT NULL,
"hubFieldType" "public"."HubFieldType" NOT NULL,
"custom_field_label" TEXT,
CONSTRAINT "ConnectorFormbricksMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ConnectorFieldMapping" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"connectorId" TEXT NOT NULL,
"environmentId" TEXT NOT NULL,
"source_field_id" TEXT NOT NULL,
"target_field_id" TEXT NOT NULL,
"static_value" TEXT,
CONSTRAINT "ConnectorFieldMapping_pkey" PRIMARY KEY ("id")
);
-- Connector indexes
CREATE UNIQUE INDEX "Connector_id_environmentId_key" ON "public"."Connector"("id", "environmentId");
CREATE UNIQUE INDEX "Connector_environmentId_name_key" ON "public"."Connector"("environmentId", "name");
CREATE INDEX "Connector_type_idx" ON "public"."Connector"("type");
-- ConnectorFormbricksMapping indexes
CREATE UNIQUE INDEX "ConnectorFormbricksMapping_environmentId_connectorId_survey_key" ON "public"."ConnectorFormbricksMapping"("environmentId", "connectorId", "surveyId", "elementId");
CREATE INDEX "ConnectorFormbricksMapping_environmentId_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("environmentId", "surveyId");
CREATE INDEX "ConnectorFormbricksMapping_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("surveyId");
-- ConnectorFieldMapping indexes
CREATE UNIQUE INDEX "ConnectorFieldMapping_environmentId_connectorId_source_fiel_key" ON "public"."ConnectorFieldMapping"("environmentId", "connectorId", "source_field_id", "target_field_id");
-- Survey composite unique (for composite FK from ConnectorFormbricksMapping)
CREATE UNIQUE INDEX "Survey_id_environmentId_key" ON "public"."Survey"("id", "environmentId");
-- Foreign keys: Connector -> Environment
ALTER TABLE "public"."Connector" ADD CONSTRAINT "Connector_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "public"."Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Foreign keys: ConnectorFormbricksMapping -> Connector (composite), Survey (composite)
ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_connectorId_environmentId_fkey" FOREIGN KEY ("connectorId", "environmentId") REFERENCES "public"."Connector"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_surveyId_environmentId_fkey" FOREIGN KEY ("surveyId", "environmentId") REFERENCES "public"."Survey"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE;
-- Foreign keys: ConnectorFieldMapping -> Connector (composite)
ALTER TABLE "public"."ConnectorFieldMapping" ADD CONSTRAINT "ConnectorFieldMapping_connectorId_environmentId_fkey" FOREIGN KEY ("connectorId", "environmentId") REFERENCES "public"."Connector"("id", "environmentId") ON DELETE CASCADE ON UPDATE CASCADE;
+109 -6
View File
@@ -392,25 +392,27 @@ model Survey {
/// [SurveySingleUse]
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
isVerifyEmailEnabled Boolean @default(false)
isSingleResponsePerEmailEnabled Boolean @default(false)
isBackButtonHidden Boolean @default(false)
isCaptureIpEnabled Boolean @default(false)
isVerifyEmailEnabled Boolean @default(false)
isSingleResponsePerEmailEnabled Boolean @default(false)
isBackButtonHidden Boolean @default(false)
isCaptureIpEnabled Boolean @default(false)
pin String?
displayPercentage Decimal?
languages SurveyLanguage[]
showLanguageSwitch Boolean?
followUps SurveyFollowUp[]
/// [SurveyRecaptcha]
recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}")
recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}")
/// [SurveyLinkMetadata]
metadata Json @default("{}")
metadata Json @default("{}")
connectorMappings ConnectorFormbricksMapping[]
slug String? @unique
customHeadScripts String?
customHeadScriptsMode SurveyScriptMode? @default(add)
@@unique([id, environmentId])
@@index([environmentId, updatedAt])
@@index([segmentId])
}
@@ -595,6 +597,7 @@ model Environment {
segments Segment[]
integration Integration[]
ApiKeyEnvironment ApiKeyEnvironment[]
connectors Connector[]
@@index([projectId])
}
@@ -1004,3 +1007,103 @@ model ProjectTeam {
@@id([projectId, teamId])
@@index([teamId])
}
enum ConnectorType {
formbricks
csv
}
enum ConnectorStatus {
active
paused
error
}
enum HubFieldType {
text
categorical
nps
csat
ces
rating
number
boolean
date
}
/// Base connector for all integration types.
/// Connects external data sources to the Hub for feedback record creation.
///
/// @property id - Unique identifier for the connector
/// @property name - Display name for the connector
/// @property type - Type of connector (formbricks, webhook, csv, email, slack)
/// @property status - Current state of the connector (active, paused, error)
/// @property environment - The environment this connector belongs to
/// @property config - Type-specific configuration (e.g., webhook secret, S3 config)
/// @property formbricksMappings - Element mappings for Formbricks connectors
/// @property fieldMappings - Field mappings for other connector types
model Connector {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type ConnectorType
status ConnectorStatus @default(active)
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
formbricksMappings ConnectorFormbricksMapping[]
fieldMappings ConnectorFieldMapping[]
lastSyncAt DateTime? @map(name: "last_sync_at")
errorMessage String? @map(name: "error_message")
@@unique([id, environmentId])
@@unique([environmentId, name])
@@index([type])
}
/// Maps survey elements to Hub FeedbackRecords for Formbricks connectors.
/// Each row represents one element that will create FeedbackRecords when answered.
///
/// @property id - Unique identifier for the mapping
/// @property connector - The parent connector
/// @property survey - The survey containing the element
/// @property elementId - The element ID within the survey (from blocks[].elements[].id)
/// @property hubFieldType - The field_type to use in Hub (text, nps, rating, etc.)
/// @property customFieldLabel - Optional override for the element headline as field_label in Hub
model ConnectorFormbricksMapping {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
connectorId String
environmentId String
connector Connector @relation(fields: [connectorId, environmentId], references: [id, environmentId], onDelete: Cascade)
surveyId String
survey Survey @relation(fields: [surveyId, environmentId], references: [id, environmentId], onDelete: Cascade)
elementId String
hubFieldType HubFieldType
customFieldLabel String? @map(name: "custom_field_label")
@@unique([environmentId, connectorId, surveyId, elementId])
@@index([environmentId, surveyId])
@@index([surveyId])
}
/// Generic field mapping for Webhook, CSV, Email, Slack connectors.
/// Maps source fields to Hub FeedbackRecord fields.
///
/// @property id - Unique identifier for the mapping
/// @property connector - The parent connector
/// @property sourceFieldId - Field path for webhook (e.g., "user.id"), column name for CSV
/// @property targetFieldId - Hub field (collected_at, field_id, value_text, etc.)
/// @property staticValue - If set, use this value instead of reading from sourceFieldId
model ConnectorFieldMapping {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
connectorId String
environmentId String
connector Connector @relation(fields: [connectorId, environmentId], references: [id, environmentId], onDelete: Cascade)
sourceFieldId String @map(name: "source_field_id")
targetFieldId String @map(name: "target_field_id")
staticValue String? @map(name: "static_value")
@@unique([environmentId, connectorId, sourceFieldId, targetFieldId])
}
@@ -46,6 +46,10 @@ const TRANSLATION_PATTERNS = [
/i18nKey\s*=\s*\{\s*["'](?<temp1>[^"']+)["']\s*\}/g,
];
// Extracts string literals from dynamic i18nKey={...} expressions (e.g. ternaries)
const I18N_KEY_BLOCK_PATTERN = /i18nKey\s*=\s*\{(?<block>[\s\S]*?)\}/g;
const STRING_LITERAL_PATTERN = /["'](?<key>[^"']+)["']/g;
// Directories and files to exclude from scanning
const EXCLUDE_DIRS = [
"**/node_modules/**",
@@ -134,6 +138,21 @@ export function extractKeysFromContent(content: string): string[] {
}
}
// Extract keys from dynamic i18nKey={...} expressions (e.g. ternaries, conditionals)
I18N_KEY_BLOCK_PATTERN.lastIndex = 0;
let blockMatch: RegExpExecArray | null = null;
while ((blockMatch = I18N_KEY_BLOCK_PATTERN.exec(contentWithoutComments)) !== null) {
const blockContent = blockMatch.groups?.block ?? "";
STRING_LITERAL_PATTERN.lastIndex = 0;
let strMatch: RegExpExecArray | null = null;
while ((strMatch = STRING_LITERAL_PATTERN.exec(blockContent)) !== null) {
const key = strMatch.groups?.key ?? "";
if (key.includes(".") && !key.includes("${") && !key.includes(" ")) {
keys.push(key);
}
}
}
return keys;
}
@@ -82,7 +82,7 @@ export function SurveyContainer({
aria-live="assertive"
className={cn(
hasOverlay ? "pointer-events-auto" : "pointer-events-none",
isModal && "fixed inset-0 z-999999 flex items-end"
isModal && "z-999999 fixed inset-0 flex items-end"
)}>
<div
className={cn(
+146
View File
@@ -0,0 +1,146 @@
import { z } from "zod";
// Connector type enum
export const ZConnectorType = z.enum(["formbricks", "csv"]);
export type TConnectorType = z.infer<typeof ZConnectorType>;
// Connector status enum
export const ZConnectorStatus = z.enum(["active", "paused", "error"]);
export type TConnectorStatus = z.infer<typeof ZConnectorStatus>;
// Hub field types (from Hub OpenAPI spec)
export const ZHubFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
export type THubFieldType = z.infer<typeof ZHubFieldType>;
// Hub target fields for mapping
export const ZHubTargetField = z.enum([
"collected_at",
"source_type",
"field_id",
"field_type",
"field_label",
"field_group_id",
"field_group_label",
"tenant_id",
"source_id",
"source_name",
"value_text",
"value_number",
"value_boolean",
"value_date",
"metadata",
"language",
"user_identifier",
]);
export type THubTargetField = z.infer<typeof ZHubTargetField>;
// Base connector schema
export const ZConnector = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string().min(1),
type: ZConnectorType,
status: ZConnectorStatus,
environmentId: z.string().cuid2(),
lastSyncAt: z.date().nullable(),
errorMessage: z.string().nullable(),
});
export type TConnector = z.infer<typeof ZConnector>;
// Formbricks element mapping
export const ZConnectorFormbricksMapping = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
connectorId: z.string().cuid2(),
environmentId: z.string().cuid2(),
surveyId: z.string().cuid2(),
elementId: z.string(),
hubFieldType: ZHubFieldType,
customFieldLabel: z.string().nullable(),
});
export type TConnectorFormbricksMapping = z.infer<typeof ZConnectorFormbricksMapping>;
export const ZConnectorFieldMapping = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
connectorId: z.string().cuid2(),
environmentId: z.string().cuid2(),
sourceFieldId: z.string(),
targetFieldId: ZHubTargetField,
staticValue: z.string().nullable(),
});
export type TConnectorFieldMapping = z.infer<typeof ZConnectorFieldMapping>;
export const ZConnectorWithMappings = ZConnector.extend({
formbricksMappings: z.array(ZConnectorFormbricksMapping),
fieldMappings: z.array(ZConnectorFieldMapping),
});
export type TConnectorWithMappings = z.infer<typeof ZConnectorWithMappings>;
// Create input schemas
export const ZConnectorCreateInput = z.object({
name: z.string().min(1),
type: ZConnectorType,
});
export type TConnectorCreateInput = z.infer<typeof ZConnectorCreateInput>;
// Create Formbricks mapping input
export const ZConnectorFormbricksMappingCreateInput = z.object({
surveyId: z.string().cuid2(),
elementId: z.string(),
hubFieldType: ZHubFieldType,
customFieldLabel: z.string().optional(),
});
export type TConnectorFormbricksMappingCreateInput = z.infer<typeof ZConnectorFormbricksMappingCreateInput>;
// Create field mapping input
export const ZConnectorFieldMappingCreateInput = z.object({
sourceFieldId: z.string(),
targetFieldId: ZHubTargetField,
staticValue: z.string().optional(),
});
export type TConnectorFieldMappingCreateInput = z.infer<typeof ZConnectorFieldMappingCreateInput>;
// Update connector input
export const ZConnectorUpdateInput = z.object({
name: z.string().min(1).optional(),
status: ZConnectorStatus.optional(),
errorMessage: z.string().nullable().optional(),
lastSyncAt: z.date().nullable().optional(),
});
export type TConnectorUpdateInput = z.infer<typeof ZConnectorUpdateInput>;
// Element type to Hub field type mapping helper
export const ELEMENT_TYPE_TO_HUB_FIELD_TYPE: Record<string, THubFieldType> = {
openText: "text",
nps: "nps",
rating: "rating",
multipleChoiceSingle: "categorical",
multipleChoiceMulti: "categorical",
date: "date",
consent: "boolean",
matrix: "categorical",
ranking: "categorical",
pictureSelection: "categorical",
contactInfo: "text",
address: "text",
fileUpload: "text",
cal: "text",
cta: "boolean",
};
// Helper function to get Hub field type from element type
export const getHubFieldTypeFromElementType = (elementType: string): THubFieldType => {
return ELEMENT_TYPE_TO_HUB_FIELD_TYPE[elementType];
};
+3 -1
View File
@@ -254,7 +254,9 @@
"UNSPLASH_ACCESS_KEY",
"PROMETHEUS_ENABLED",
"PROMETHEUS_EXPORTER_PORT",
"USER_MANAGEMENT_MINIMUM_ROLE"
"USER_MANAGEMENT_MINIMUM_ROLE",
"HUB_API_URL",
"HUB_API_KEY"
],
"outputs": ["dist/**", ".next/**"]
},