mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 11:10:36 -05:00
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:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+25
@@ -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));
|
||||
});
|
||||
+56
@@ -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>
|
||||
);
|
||||
}
|
||||
+155
@@ -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>
|
||||
);
|
||||
}
|
||||
+124
@@ -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>
|
||||
);
|
||||
}
|
||||
+52
@@ -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>
|
||||
);
|
||||
}
|
||||
+282
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+358
@@ -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>
|
||||
);
|
||||
}
|
||||
+259
@@ -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>
|
||||
);
|
||||
}
|
||||
+222
@@ -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>
|
||||
);
|
||||
}
|
||||
+309
@@ -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">
|
||||
= “{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 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>
|
||||
);
|
||||
}
|
||||
+148
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) } }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Let’s 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 密钥",
|
||||
|
||||
@@ -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 金鑰",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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/**"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user