mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d44e18a61 | |||
| e159b45911 | |||
| 96d14b98f0 | |||
| aa90d9fd1a | |||
| 2ffe79ffd2 | |||
| cffeb0513e | |||
| 077a9934ad | |||
| 1ed8d8076e | |||
| 8b048c3105 | |||
| b2705a4f8f | |||
| e867caa373 | |||
| de79b58648 | |||
| 04d528b9b8 | |||
| c815b11015 | |||
| 1e7830d850 | |||
| 77cd1e9bd1 | |||
| e665227437 | |||
| 75e71e39bc | |||
| 337aedf463 |
@@ -0,0 +1 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = () => {
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
Shapes,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -164,6 +165,12 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
href: `/workspaces/${workspace.id}/unify/sources`,
|
||||
icon: Shapes,
|
||||
isActive: pathname?.includes("/unify"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
@@ -291,6 +298,12 @@ export const MainNavigation = ({
|
||||
href: `/workspaces/${workspace.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
|
||||
@@ -146,7 +146,7 @@ export const OrganizationBreadcrumb = ({
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
|
||||
@@ -138,6 +138,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.tags"),
|
||||
href: `${workspaceBasePath}/tags`,
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: t("common.unify"),
|
||||
href: `${workspaceBasePath}/workspace/unify`,
|
||||
},
|
||||
];
|
||||
|
||||
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
workspaceId: string;
|
||||
activeId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UnifyConfigNavigation = ({
|
||||
workspaceId,
|
||||
activeId: activeIdProp,
|
||||
loading,
|
||||
}: UnifyConfigNavigationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
|
||||
const navigation = [
|
||||
{ id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
|
||||
{
|
||||
id: "feedback-records",
|
||||
label: t("workspace.unify.feedback_records"),
|
||||
href: `${baseHref}/feedback-records`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsPageClientProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
initialFrdId={initialFrdId}
|
||||
initialRecords={initialRecords}
|
||||
initialNextCursor={initialNextCursor}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-3.5 w-3.5" />,
|
||||
categorical: <HashIcon className="h-3.5 w-3.5" />,
|
||||
nps: <HashIcon className="h-3.5 w-3.5" />,
|
||||
csat: <HashIcon className="h-3.5 w-3.5" />,
|
||||
ces: <HashIcon className="h-3.5 w-3.5" />,
|
||||
rating: <HashIcon className="h-3.5 w-3.5" />,
|
||||
number: <HashIcon className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
|
||||
date: <CalendarIcon className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
|
||||
if (record.value_text != null) return record.value_text;
|
||||
if (record.value_number != null) return String(record.value_number);
|
||||
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
|
||||
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
|
||||
return "—";
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [selectedFrdId, setSelectedFrdId] = useState<string | null>(initialFrdId);
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(initialNextCursor);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRecords = useCallback(
|
||||
async (frdId: string, cursor: string | undefined, append: boolean) => {
|
||||
const setLoading = append ? setIsLoadingMore : setIsRefreshing;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
setError(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
setRecords((prev) => (append ? [...prev, ...response.data] : response.data));
|
||||
setNextCursor(response.next_cursor);
|
||||
setLoading(false);
|
||||
},
|
||||
[workspaceId, t]
|
||||
);
|
||||
|
||||
const handleFrdChange = (frdId: string) => {
|
||||
setSelectedFrdId(frdId);
|
||||
fetchRecords(frdId, undefined, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!selectedFrdId) return;
|
||||
fetchRecords(selectedFrdId, nextCursor, true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedFrdId || isRefreshing) return;
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
await fetchRecords(selectedFrdId, undefined, false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
const hasMore = !!nextCursor;
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
const currentFrdName = directories.find((d) => d.id === selectedFrdId)?.name ?? "—";
|
||||
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<MessageSquareTextIcon className="mx-auto h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{t("workspace.unify.no_feedback_record_directory_available")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-slate-500">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
{directories.length === 1 ? (
|
||||
<p className="text-sm font-medium text-slate-900">{currentFrdName}</p>
|
||||
) : (
|
||||
<Select value={selectedFrdId ?? ""} onValueChange={handleFrdChange}>
|
||||
<SelectTrigger className="min-w-[220px]">
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isEmpty && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: records.length })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow key={record.id} record={record} locale={locale} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
|
||||
return (
|
||||
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={record.source_type} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{record.source_name ?? "—"}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-slate-600">
|
||||
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
|
||||
{record.field_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[250px] px-4 py-3">
|
||||
{isLongValue ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{truncate(value, 60)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
|
||||
{record.user_identifier ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { FeedbackRecordListResponse } from "@/modules/hub";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(props: {
|
||||
readonly params: Promise<{ workspaceId: string }>;
|
||||
}) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
|
||||
|
||||
// Preload first FRD's records server-side for fast initial render
|
||||
const initialFrdId = frds[0]?.id;
|
||||
let initialRecords: FeedbackRecordListResponse | null = null;
|
||||
|
||||
if (initialFrdId) {
|
||||
const result = await listFeedbackRecords({ tenant_id: initialFrdId, limit: INITIAL_PAGE_SIZE });
|
||||
// Don't crash if Hub is down — show empty state
|
||||
if (!result.error) {
|
||||
initialRecords = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
directories={frds}
|
||||
initialFrdId={initialFrdId ?? null}
|
||||
initialRecords={initialRecords?.data ?? []}
|
||||
initialNextCursor={initialRecords?.next_cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/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 { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
import { TUnifySurvey } from "./types";
|
||||
|
||||
const ZGetSurveysForUnifyAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveysForUnifyAction = authenticatedActionClient
|
||||
.schema(ZGetSurveysForUnifyAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(parsedInput.workspaceId);
|
||||
return surveys.map((survey) => transformToUnifySurvey(survey));
|
||||
});
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ConnectorRowDropdownProps {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConnectorRowDropdown({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorRowDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isActive = connector.status === "active";
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div // eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
data-testid="connector-row-dropdown">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<div className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">{t("workspace.surveys.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||
<DropdownMenuGroup>
|
||||
{connector.type === "csv" && onCsvImport && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onCsvImport();
|
||||
}}>
|
||||
<FileSpreadsheetIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.unify.import_csv_data")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onDuplicate();
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onToggleStatus();
|
||||
}}>
|
||||
{isActive ? <PauseIcon className="mr-2 h-4 w-4" /> : <PlayIcon className="mr-2 h-4 w-4" />}
|
||||
{isActive ? t("common.disable") : t("common.enable")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("workspace.unify.source")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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("workspace.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>
|
||||
);
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
"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,
|
||||
duplicateConnectorAction,
|
||||
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 { CsvImportModal } from "./csv-import-modal";
|
||||
import { EditConnectorModal } from "./edit-connector-modal";
|
||||
|
||||
interface ConnectorsSectionProps {
|
||||
workspaceId: string;
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
workspaceId,
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: ConnectorsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
|
||||
const handleCreateConnector = async (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}): Promise<string | undefined> => {
|
||||
const result = await createConnectorWithMappingsAction({
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : 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 undefined;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_created_successfully"));
|
||||
router.refresh();
|
||||
return result.data.id;
|
||||
};
|
||||
|
||||
const handleUpdateConnector = async (data: {
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
},
|
||||
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : 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("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_deleted_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const result = await duplicateConnectorAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_duplicated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const newStatus = connector.status === "active" ? "paused" : "active";
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: { status: newStatus },
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_status_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle={t("workspace.unify.unify_feedback")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
onCsvImport={setCsvImportConnector}
|
||||
onDuplicate={handleDuplicateConnector}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
directories={directories}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{csvImportConnector && (
|
||||
<CsvImportModal
|
||||
open={csvImportConnector !== null}
|
||||
onOpenChange={(open) => !open && setCsvImportConnector(null)}
|
||||
connectorId={csvImportConnector.id}
|
||||
workspaceId={csvImportConnector.workspaceId}
|
||||
onOpenEditConnector={() => {
|
||||
setEditingConnector(csvImportConnector);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { ConnectorRowDropdown } from "./connector-row-dropdown";
|
||||
|
||||
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) {
|
||||
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 {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getConnectorIcon(type: TConnectorType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <FormIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||
default:
|
||||
return <FormIcon 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({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableDataRowProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
return t("workspace.unify.status_active");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
return t("workspace.unify.status_error");
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
|
||||
switch (connectorType) {
|
||||
case "formbricks":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return connectorType;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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={onEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onEdit();
|
||||
}
|
||||
}}>
|
||||
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
|
||||
{getConnectorIcon(connector.type)}
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.createdAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.updatedAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableDataRow } from "./connectors-table-data-row";
|
||||
|
||||
interface ConnectorsTableRowsContainerProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_sources_connected")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{connectors.map((connector) => (
|
||||
<ConnectorsTableDataRow
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
onEdit={() => onConnectorClick(connector)}
|
||||
onCsvImport={connector.type === "csv" ? () => onCsvImport(connector) : undefined}
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableRowsContainer } from "./connectors-table-rows-container";
|
||||
|
||||
interface ConnectorsTableProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
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-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-3">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : (
|
||||
<ConnectorsTableRowsContainer
|
||||
connectors={connectors}
|
||||
onConnectorClick={onConnectorClick}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
importCsvDataAction,
|
||||
importHistoricalResponsesAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } 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;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<string | undefined>;
|
||||
surveys: TUnifySurvey[];
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "csv") return t("workspace.unify.import_csv_data");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getDialogDescription = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_description");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks") return t("workspace.unify.select_questions");
|
||||
if (type === "csv") return t("workspace.unify.configure_import");
|
||||
return t("workspace.unify.create_mapping");
|
||||
};
|
||||
|
||||
const getCreateDisabled = (
|
||||
type: TConnectorType | null,
|
||||
isFormbricksValid: boolean,
|
||||
isCsvValid: boolean,
|
||||
allRequiredMapped: boolean
|
||||
): boolean => {
|
||||
if (type === "formbricks") return !isFormbricksValid;
|
||||
if (type === "csv") return !isCsvValid || !allRequiredMapped;
|
||||
return !allRequiredMapped;
|
||||
};
|
||||
|
||||
interface AggregateImportSectionProps {
|
||||
surveyEntries: {
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
responseCount: number;
|
||||
elementCount: number;
|
||||
importHistorical: boolean;
|
||||
}[];
|
||||
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const AggregateImportSection = ({
|
||||
surveyEntries,
|
||||
onImportHistoricalChange,
|
||||
t,
|
||||
}: AggregateImportSectionProps) => {
|
||||
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
|
||||
|
||||
const checkedTotal = surveyEntries
|
||||
.filter((e) => e.importHistorical)
|
||||
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="space-y-2">
|
||||
{surveyEntries.map((entry) => (
|
||||
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.importHistorical}
|
||||
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-xs text-amber-800">
|
||||
{t("workspace.unify.survey_import_line", {
|
||||
surveyName: entry.surveyName,
|
||||
responseCount: entry.responseCount,
|
||||
questionCount: entry.elementCount,
|
||||
total: entry.responseCount * entry.elementCount,
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{surveyEntries.length > 1 && (
|
||||
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
|
||||
{t("workspace.unify.total_feedback_records", {
|
||||
checked: checkedTotal,
|
||||
total: totalRecords,
|
||||
surveyCount: checkedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateConnectorModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateConnector,
|
||||
surveys,
|
||||
workspaceId,
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultConnectorName: Record<TConnectorType, string> = {
|
||||
formbricks: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.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 [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
|
||||
|
||||
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories.length === 1 ? directories[0].id : null
|
||||
);
|
||||
|
||||
const fetchResponseCount = useCallback(
|
||||
async (surveyId: string) => {
|
||||
if (responseCountBySurvey[surveyId] !== undefined) return;
|
||||
try {
|
||||
const result = await getResponseCountAction({ surveyId, workspaceId });
|
||||
if (result?.data !== undefined) {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
|
||||
}
|
||||
} catch {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[workspaceId, responseCountBySurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && selectedType === "formbricks") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [selectedSurveyId, selectedType, fetchResponseCount]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
setResponseCountBySurvey({});
|
||||
setImportHistoricalBySurvey({});
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories.length === 1 ? directories[0].id : null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (isImporting) return;
|
||||
if (!newOpen) resetForm();
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
setConnectorName(
|
||||
selectedType === "formbricks" && selectedSurvey
|
||||
? `${selectedSurvey.name} ${t("workspace.unify.connection")}`
|
||||
: defaultConnectorName[selectedType]
|
||||
);
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements
|
||||
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
|
||||
.map((e) => e.id),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getSurveyMappings = () =>
|
||||
Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
const handleHistoricalImports = async (connectorId: string) => {
|
||||
const surveysToImport = Object.entries(importHistoricalBySurvey)
|
||||
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
|
||||
.map(([surveyId]) => surveyId);
|
||||
|
||||
if (surveysToImport.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
let totalSuccesses = 0;
|
||||
let totalFailures = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const surveyId of surveysToImport) {
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
if (importResult?.data) {
|
||||
totalSuccesses += importResult.data.successes;
|
||||
totalFailures += importResult.data.failures;
|
||||
totalSkipped += importResult.data.skipped;
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
}
|
||||
|
||||
setIsImporting(false);
|
||||
|
||||
if (totalSuccesses > 0 || totalFailures > 0) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: totalSuccesses,
|
||||
failures: totalFailures,
|
||||
skipped: totalSkipped,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCsvImport = async (connectorId: string) => {
|
||||
setIsImporting(true);
|
||||
const importResult = await importCsvDataAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
csvData: csvParsedData,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
|
||||
|
||||
if (selectedType === "csv" && csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
setEnumValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const surveyMappings = getSurveyMappings();
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: connectorName.trim(),
|
||||
type: selectedType,
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && selectedType === "formbricks") {
|
||||
await handleHistoricalImports(connectorId);
|
||||
}
|
||||
|
||||
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
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 hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
|
||||
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("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{isImporting && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-white/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-slate-500" />
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.importing_historical_data")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getDialogTitle(currentStep, selectedType, t)}</DialogTitle>
|
||||
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{(() => {
|
||||
const entries = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, ids]) => ({
|
||||
surveyId,
|
||||
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
|
||||
responseCount: responseCountBySurvey[surveyId] ?? 0,
|
||||
elementCount: ids.length,
|
||||
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
|
||||
}))
|
||||
.filter((e) => e.responseCount > 0);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AggregateImportSection
|
||||
surveyEntries={entries}
|
||||
onImportHistoricalChange={(surveyId, checked) => {
|
||||
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<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={(m) => {
|
||||
setMappings(m);
|
||||
setEnumValidationErrors([]);
|
||||
}}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
onParsedDataChange={setCsvParsedData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enumValidationErrors.length > 0 && (
|
||||
<Alert variant="error" size="small">
|
||||
{enumValidationErrors.map((err) => {
|
||||
const uniqueValues = [...new Set(err.invalidEntries.map((e) => e.value))];
|
||||
const rowNumbers = err.invalidEntries.slice(0, 5).map((e) => e.row);
|
||||
return (
|
||||
<div key={err.targetFieldName} className="text-xs">
|
||||
<p className="font-medium">
|
||||
{t("workspace.unify.invalid_enum_values", {
|
||||
field: err.targetFieldName,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("workspace.unify.invalid_values_found", {
|
||||
values: uniqueValues.join(", "),
|
||||
rows: rowNumbers.join(", "),
|
||||
extra: err.invalidEntries.length > 5 ? `+${err.invalidEntries.length - 5}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-500">
|
||||
{t("workspace.unify.allowed_values", {
|
||||
values: err.allowedValues.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack} disabled={isCreating || isImporting}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType}>
|
||||
{getNextStepButtonLabel(selectedType, t)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!connectorName.trim() ||
|
||||
!selectedDirectoryId ||
|
||||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
|
||||
}>
|
||||
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("workspace.unify.setup_connection")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface FrdPickerProps {
|
||||
directories: { id: string; name: string }[];
|
||||
selectedDirectoryId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId, t }: FrdPickerProps) => {
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (directories.length === 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{directories[0].name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackRecordDirectory">{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger id="feedbackRecordDirectory">
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon } 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 { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface CsvConnectorUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
onParsedDataChange?: (data: Record<string, string>[]) => void;
|
||||
}
|
||||
|
||||
export function CsvConnectorUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
onParsedDataChange,
|
||||
}: 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 handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
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.issues[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);
|
||||
onParsedDataChange?.(validRecords);
|
||||
setShowMapping(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("common.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-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setCsvError("");
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
onParsedDataChange?.([]);
|
||||
}}>
|
||||
{t("workspace.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("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
connectorType="csv"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("workspace.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("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.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("workspace.unify.load_sample_csv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, Loader2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { importCsvDataAction } from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
|
||||
interface CsvImportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
onOpenEditConnector?: () => void;
|
||||
}
|
||||
|
||||
export function CsvImportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
connectorId,
|
||||
workspaceId,
|
||||
onOpenEditConnector,
|
||||
}: CsvImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [parsedData, setParsedData] = useState<Record<string, string>[]>([]);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
file
|
||||
.text()
|
||||
.then((csv) => {
|
||||
const records = parse(csv, { columns: true, skip_empty_lines: true });
|
||||
const result = createFeedbackCSVDataSchema(t).safeParse(records);
|
||||
|
||||
if (!result.success) {
|
||||
setCsvError(result.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setParsedData(result.data);
|
||||
setRowCount(result.data.length);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
setCsvError(message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) processCSVFile(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 handleImport = async () => {
|
||||
if (parsedData.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
const result = await importCsvDataAction({ connectorId, workspaceId, csvData: parsedData });
|
||||
setIsImporting(false);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: result.data.successes,
|
||||
failures: result.data.failures,
|
||||
skipped: result.data.skipped,
|
||||
})
|
||||
);
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
setCsvError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.import_csv_data")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.unify.upload_csv_data_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Alert variant="info" size="small">
|
||||
{t("workspace.unify.csv_import_duplicate_warning")}
|
||||
</Alert>
|
||||
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{csvFile ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${rowCount} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleClear} disabled={isImporting}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-import-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("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-import-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onOpenEditConnector && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onOpenEditConnector();
|
||||
}}>
|
||||
{t("workspace.unify.edit_csv_mapping")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleImport} disabled={parsedData.length === 0 || isImporting}>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("workspace.unify.importing_data")}
|
||||
</>
|
||||
) : (
|
||||
t("workspace.unify.import_rows", { count: rowCount })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, 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 {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
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;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
const 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" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const groupMappingsBySurvey = (
|
||||
mappings: { surveyId: string; elementId: string }[]
|
||||
): Record<string, string[]> => {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const m of mappings) {
|
||||
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
|
||||
grouped[m.surveyId].push(m.elementId);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
directories,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
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))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connector) {
|
||||
setConnectorName(connector.name);
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
const fbMappings = connector.formbricksMappings;
|
||||
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
|
||||
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
|
||||
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);
|
||||
setElementIdsBySurvey({});
|
||||
} else {
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
}
|
||||
}
|
||||
}, [connector]);
|
||||
|
||||
const resetForm = () => {
|
||||
setConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements.map((e) => e.id),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!connector || !connectorName.trim()) return;
|
||||
|
||||
const surveyMappings = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: connectorName.trim(),
|
||||
surveyMappings:
|
||||
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const assignedDirectoryName =
|
||||
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
|
||||
connector?.feedbackRecordDirectoryId ??
|
||||
"—";
|
||||
|
||||
const saveChangesDisbaled = useMemo(() => {
|
||||
if (!connector) return true;
|
||||
if (!connectorName.trim()) return true;
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !allRequiredMapped;
|
||||
}
|
||||
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.edit_source_connection")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.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("workspace.unify.source_type_cannot_be_changed")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{assignedDirectoryName}</span>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.frd_cannot_be_changed")}</p>
|
||||
</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-[40vh] 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>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
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;
|
||||
}
|
||||
|
||||
const getElementIcon = (type: TSurveyElementTypeEnum) => {
|
||||
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" />;
|
||||
}
|
||||
};
|
||||
|
||||
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
|
||||
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
|
||||
};
|
||||
|
||||
export const FormbricksSurveySelector = ({
|
||||
surveys,
|
||||
selectedSurveyId,
|
||||
selectedElementIds,
|
||||
onSurveySelect,
|
||||
onElementToggle,
|
||||
onSelectAllElements,
|
||||
onDeselectAllElements,
|
||||
}: FormbricksSurveySelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
|
||||
const allSupportedSelected =
|
||||
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
|
||||
|
||||
const handleSurveyClick = (survey: TUnifySurvey) => {
|
||||
if (selectedSurveyId !== survey.id) {
|
||||
onSurveySelect(survey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSupported = (surveyId: string) => {
|
||||
onSelectAllElements(surveyId);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TUnifySurvey["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text={t("workspace.unify.status_active")} type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text={t("workspace.unify.status_paused")} type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text={t("workspace.unify.status_draft")} type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text={t("workspace.unify.status_completed")} type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSupportedElementCount = (survey: TUnifySurvey) =>
|
||||
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
|
||||
|
||||
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
|
||||
if (isSelected) return "border-green-300 bg-green-50";
|
||||
return "border-slate-200 bg-white hover:border-slate-300";
|
||||
};
|
||||
|
||||
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "border border-slate-200 bg-slate-100";
|
||||
if (isSelected) return "bg-green-500 text-white";
|
||||
return "border border-slate-300 bg-white";
|
||||
};
|
||||
|
||||
const renderElementPanel = () => {
|
||||
if (!selectedSurvey) {
|
||||
return (
|
||||
<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("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedSurvey.elements.length === 0) {
|
||||
return (
|
||||
<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("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 overflow-y-auto pr-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{selectedSurvey.elements.map((element) => {
|
||||
const isSelected = selectedElementIds.includes(element.id);
|
||||
const unsupported = isUnsupportedType(element.type);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={element.id}
|
||||
type="button"
|
||||
disabled={unsupported}
|
||||
onClick={() => onElementToggle(element.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
|
||||
{isSelected && !unsupported && <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 ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
|
||||
{element.headline}
|
||||
</p>
|
||||
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
|
||||
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<Tooltip key={element.id}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.unify.question_type_not_supported")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</TooltipProvider>
|
||||
|
||||
{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
|
||||
? "workspace.unify.question_selected"
|
||||
: "workspace.unify.questions_selected"
|
||||
}
|
||||
values={{ count: selectedElementIds.length }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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("workspace.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("workspace.unify.no_surveys_found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
surveys.map((survey) => {
|
||||
const isSelected = selectedSurveyId === survey.id;
|
||||
|
||||
return (
|
||||
<div key={survey.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSurveyClick(survey)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
|
||||
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
|
||||
}`}>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div>{getStatusBadge(survey.status)}</div>
|
||||
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.n_supported_questions", {
|
||||
count: getSupportedElementCount(survey),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <ChevronRightIcon className="h-5 w-5 shrink-0 text-brand-dark" />}
|
||||
</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("workspace.unify.select_questions")}</h4>
|
||||
{selectedSurvey && supportedElements.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allSupportedSelected ? t("workspace.unify.deselect_all") : t("workspace.unify.select_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderElementPanel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
"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 { cn } from "@/modules/ui/lib/utils";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "../types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
}
|
||||
|
||||
export const 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>
|
||||
);
|
||||
};
|
||||
|
||||
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
|
||||
if (isActive) return "border-brand-dark bg-slate-100";
|
||||
if (hasMapping) return "border-green-300 bg-green-50";
|
||||
return "border-dashed border-slate-300 bg-slate-50";
|
||||
};
|
||||
|
||||
interface RemoveMappingButtonProps {
|
||||
onClick: () => void;
|
||||
variant: "green" | "blue";
|
||||
}
|
||||
|
||||
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
|
||||
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
|
||||
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
|
||||
<XIcon className={`h-3 w-3 ${iconClass}`} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnumTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const EnumTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: EnumTargetFieldContentProps) => {
|
||||
return (
|
||||
<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("workspace.unify.enum")}</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
) : (
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues?.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StringTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
hasMapping: unknown;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const StringTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
hasMapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: StringTargetFieldContentProps) => {
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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("workspace.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>
|
||||
)}
|
||||
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.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("workspace.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>
|
||||
);
|
||||
};
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export const DroppableTargetField = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
const containerClass = cn(
|
||||
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
|
||||
getMappingStateClass(!!isActive, hasMapping)
|
||||
);
|
||||
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<EnumTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<StringTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
hasMapping={hasMapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return t("workspace.unify.feedback_date");
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<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>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
"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("workspace.unify.csv_columns") : t("workspace.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("workspace.unify.click_load_sample_csv")
|
||||
: t("workspace.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("workspace.unify.feedback_record_fields")}
|
||||
</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.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("workspace.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="rounded-md border border-brand-dark 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,42 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { ConnectorsSection } from "./components/connectors-page-client";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
|
||||
return (
|
||||
<ConnectorsSection
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "source_type",
|
||||
name: "Source Type",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Type of source (e.g., survey, review, support)",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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("workspace.unify.csv_at_least_one_row") })
|
||||
.max(MAX_CSV_VALUES.RECORDS, {
|
||||
message: t("workspace.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("workspace.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("workspace.unify.csv_empty_column_headers"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
|
||||
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MAX_CSV_VALUES, TSourceField } from "./types";
|
||||
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } 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("workspace.unify.formbricks_surveys");
|
||||
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
|
||||
expect(options[1].name).toBe("workspace.unify.csv_import");
|
||||
expect(options[1].description).toBe("workspace.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");
|
||||
});
|
||||
});
|
||||
|
||||
const createMockFile = (name: string, size: number, type: string): File =>
|
||||
new File(["x".repeat(size)], name, { type });
|
||||
|
||||
describe("validateCsvFile", () => {
|
||||
test("accepts a valid .csv file", () => {
|
||||
const file = createMockFile("data.csv", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file without .csv extension", () => {
|
||||
const file = createMockFile("data.xlsx", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("rejects a file with wrong MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "application/json");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with empty MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with alternative csv MIME type", () => {
|
||||
const file = createMockFile("report.csv", 512, "application/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file exceeding the size limit", () => {
|
||||
const file = createMockFile("big.csv", MAX_CSV_VALUES.FILE_SIZE + 1, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_file_too_large" });
|
||||
});
|
||||
|
||||
test("accepts a file exactly at the size limit", () => {
|
||||
const file = createMockFile("exact.csv", MAX_CSV_VALUES.FILE_SIZE, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("checks extension before MIME type", () => {
|
||||
const file = createMockFile("data.txt", 100, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, 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("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "csv",
|
||||
name: t("workspace.unify.csv_import"),
|
||||
description: t("workspace.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}` };
|
||||
});
|
||||
};
|
||||
|
||||
export interface TEnumValidationError {
|
||||
targetFieldName: string;
|
||||
invalidEntries: { row: number; value: string }[];
|
||||
allowedValues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that CSV columns mapped to enum target fields contain only allowed values.
|
||||
* Returns an array of validation errors (empty if all valid).
|
||||
*/
|
||||
export const validateEnumMappings = (
|
||||
mappings: TFieldMapping[],
|
||||
csvData: Record<string, string>[]
|
||||
): TEnumValidationError[] => {
|
||||
const errors: TEnumValidationError[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!mapping.sourceFieldId || mapping.staticValue) continue;
|
||||
|
||||
const targetField = FEEDBACK_RECORD_FIELDS.find((f) => f.id === mapping.targetFieldId);
|
||||
if (targetField?.type !== "enum" || !targetField?.enumValues) continue;
|
||||
|
||||
const allowedValues = new Set(targetField.enumValues);
|
||||
const invalidEntries: { row: number; value: string }[] = [];
|
||||
|
||||
for (let i = 0; i < csvData.length; i++) {
|
||||
const value = csvData[i][mapping.sourceFieldId]?.trim();
|
||||
if (value && !allowedValues.has(value as THubFieldType)) {
|
||||
invalidEntries.push({ row: i + 1, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidEntries.length > 0) {
|
||||
errors.push({
|
||||
targetFieldName: targetField.name,
|
||||
invalidEntries,
|
||||
allowedValues: targetField.enumValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateCsvFile = (
|
||||
file: File,
|
||||
t: TFunction
|
||||
): { valid: true } | { valid: false; error: string } => {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
|
||||
return { valid: false, error: t("workspace.unify.csv_file_too_large") };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
@@ -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, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -153,6 +154,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, workspaceId);
|
||||
} 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(workspaceId),
|
||||
|
||||
+122
-1
@@ -159,6 +159,7 @@ checksums:
|
||||
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -191,6 +192,7 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/enable: 463972a7a95f50f3105d09b92508f2cd
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
@@ -205,6 +207,7 @@ checksums:
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
@@ -273,6 +276,7 @@ checksums:
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no: 8c708225830b06df2d1141c536f2a0d6
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
@@ -349,6 +353,7 @@ checksums:
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
common/restart: bab6232e89f24e3129f8e48268739d5b
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/role: 53743bbb6ca938f5b893552e839d067f
|
||||
common/saas: f01686245bcfb35a3590ab56db677bdb
|
||||
common/sales: 38758eb50094cd8190a71fe67be4d647
|
||||
@@ -420,6 +425,7 @@ checksums:
|
||||
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unify: bdb518a1e62f51049ccc4366b909fb0a
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
@@ -437,6 +443,7 @@ checksums:
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
common/view: 36a9b5e3dc153c036d320460d72a03c3
|
||||
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
|
||||
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
|
||||
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
|
||||
@@ -455,6 +462,7 @@ checksums:
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/yes: ec580fd11a45779b039466f1e35eed2a
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
@@ -2207,6 +2215,7 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/archive_not_allowed: 3ffe3336572a633406858887de60a470
|
||||
workspace/settings/feedback_record_directories/are_you_sure_you_want_to_archive: d249e6e8bc0345835a13f70856eb1c30
|
||||
workspace/settings/feedback_record_directories/assign_workspaces_description: 6c3f0bbf3bd7744bb313f4cd7886e184
|
||||
workspace/settings/feedback_record_directories/connectors_description: 6efec0b94291db18124e8bfb1ced7e89
|
||||
workspace/settings/feedback_record_directories/create_feedback_directory: c178dd6dbd702398df3ac08a9fa43324
|
||||
workspace/settings/feedback_record_directories/description: 8f56b169cb38d8c7b2697bf3a3ed7a61
|
||||
workspace/settings/feedback_record_directories/directory_archived_successfully: fba5b99ced59d0546c8f2241c092a5dd
|
||||
@@ -2218,12 +2227,13 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/directory_unarchived_successfully: 08d56e260decc62fe664b50ab774b728
|
||||
workspace/settings/feedback_record_directories/directory_updated_successfully: 638cb6c92f535328d809274cf2be4d7d
|
||||
workspace/settings/feedback_record_directories/empty_state: 665593dcb7cfa081a3e719677d0f6b0d
|
||||
workspace/settings/feedback_record_directories/enter_directory_name: a1c950988199bb4c4e014dcf430cce41
|
||||
workspace/settings/feedback_record_directories/error_directory_has_connectors: 792ca3a69d639f4fb602dd72daf5a806
|
||||
workspace/settings/feedback_record_directories/error_directory_name_duplicate: 349d650f562cff96b084787126323ca2
|
||||
workspace/settings/feedback_record_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
|
||||
workspace/settings/feedback_record_directories/error_directory_workspaces_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
|
||||
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
|
||||
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
|
||||
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
@@ -3215,6 +3225,117 @@ checksums:
|
||||
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
|
||||
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
workspace/unify/connection: 421e709602c92ffbe04a266f6a092089
|
||||
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
workspace/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
|
||||
workspace/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
workspace/unify/connectors: 4d6f256254573013a8714c2afe98dcc2
|
||||
workspace/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
workspace/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
workspace/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
|
||||
workspace/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
workspace/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
|
||||
workspace/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
|
||||
workspace/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
workspace/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
workspace/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
|
||||
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
workspace/unify/frd_cannot_be_changed: 265c12529f540d8309811f4e0090272f
|
||||
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
|
||||
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
|
||||
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
|
||||
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
workspace/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
|
||||
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
workspace/unify/question_selected: b9ff13b6212874258da911867932dc7d
|
||||
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
workspace/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
|
||||
workspace/unify/records_will_go_to: 6a3f5a6580857a931bab389ad354831c
|
||||
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
workspace/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
|
||||
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
workspace/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
|
||||
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
workspace/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
workspace/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
workspace/unify/showing_count_loaded: f443aae08223b65fbd5521d6e69534a4
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
|
||||
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
|
||||
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
workspace/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
|
||||
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
|
||||
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
|
||||
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnectorWithMappings,
|
||||
THubFieldType,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorFieldMappingCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
getHubFieldTypeFromElementType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
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,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getOrganizationIdFromWorkspaceId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
|
||||
import { importCsvData } from "./csv-import";
|
||||
import { importHistoricalResponses } from "./import";
|
||||
import {
|
||||
TMappingsInput,
|
||||
createConnectorWithMappings,
|
||||
deleteConnector,
|
||||
getConnectorWithMappingsById,
|
||||
updateConnector,
|
||||
updateConnectorWithMappings,
|
||||
} from "./service";
|
||||
|
||||
const ZDeleteConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const deleteConnectorAction = authenticatedActionClient
|
||||
.inputSchema(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: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteConnector(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
}
|
||||
);
|
||||
|
||||
const resolveSurveyMappings = async (
|
||||
surveyId: string,
|
||||
elementIds: string[]
|
||||
): Promise<{ surveyId: string; elementId: string; hubFieldType: THubFieldType }[]> => {
|
||||
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]));
|
||||
|
||||
return 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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveFormbricksMappingsInput = async (
|
||||
entries: { surveyId: string; elementIds: string[] }[]
|
||||
): Promise<TMappingsInput> => {
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
surveyId: ZId,
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["formbricksMappings"],
|
||||
message: "At least one survey mapping is required for Formbricks connectors",
|
||||
});
|
||||
}
|
||||
} else if (data.connectorInput.type === "csv") {
|
||||
if (!data.fieldMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["fieldMappings"],
|
||||
message: "At least one field mapping is required for CSV connectors",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateConnectorWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify FRD belongs to same org
|
||||
const frd = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (frd?.organizationId !== organizationId) {
|
||||
throw new AuthorizationError("Invalid feedback record directory");
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
const { formbricksMappings, fieldMappings } = parsedInput;
|
||||
|
||||
if (formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
|
||||
} else if (fieldMappings?.length) {
|
||||
mappingsInput = { type: "field", mappings: fieldMappings };
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
});
|
||||
|
||||
const ZUpdateConnectorWithMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorUpdateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
});
|
||||
|
||||
export const updateConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(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: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (parsedInput.formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
parsedInput.formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
|
||||
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
|
||||
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
|
||||
}
|
||||
|
||||
return updateConnectorWithMappings(
|
||||
parsedInput.connectorId,
|
||||
parsedInput.workspaceId,
|
||||
parsedInput.connectorInput,
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZDuplicateConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateConnectorAction = authenticatedActionClient
|
||||
.inputSchema(ZDuplicateConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateConnectorAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const source = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!source) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
hubFieldType: m.hubFieldType,
|
||||
customFieldLabel: m.customFieldLabel ?? undefined,
|
||||
})),
|
||||
};
|
||||
} else if (source.fieldMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings: source.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{
|
||||
name: `${source.name} (copy)`,
|
||||
type: source.type,
|
||||
feedbackRecordDirectoryId: source.feedbackRecordDirectoryId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetResponseCountAction = z.object({
|
||||
surveyId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getResponseCountAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponseCountAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetResponseCountAction>;
|
||||
}): Promise<number> => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportHistoricalResponsesAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const importHistoricalResponsesAction = authenticatedActionClient
|
||||
.inputSchema(ZImportHistoricalResponsesAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportHistoricalResponsesAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
return importHistoricalResponses(connector, survey);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportCsvDataAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
csvData: z.array(z.record(z.string(), z.string())).min(1),
|
||||
});
|
||||
|
||||
export const importCsvDataAction = authenticatedActionClient
|
||||
.inputSchema(ZImportCsvDataAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportCsvDataAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const result = await importCsvData(connector, parsedInput.csvData);
|
||||
|
||||
if (result.successes > 0) {
|
||||
await updateConnector(parsedInput.connectorId, parsedInput.workspaceId, {
|
||||
lastSyncAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const ZListFeedbackRecordsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
frdId: ZId,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
cursor: z.string().optional(),
|
||||
sourceType: z.string().optional(),
|
||||
fieldType: z
|
||||
.enum(["text", "categorical", "nps", "csat", "ces", "rating", "number", "boolean", "date"])
|
||||
.optional(),
|
||||
since: z.string().optional(),
|
||||
until: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listFeedbackRecordsAction = authenticatedActionClient
|
||||
.inputSchema(ZListFeedbackRecordsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZListFeedbackRecordsAction>;
|
||||
}): Promise<FeedbackRecordListResponse> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify FRD belongs to workspace's accessible FRDs
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
|
||||
if (!frds.some((f) => f.id === parsedInput.frdId)) {
|
||||
throw new Error("Feedback record directory not accessible");
|
||||
}
|
||||
|
||||
const params: FeedbackRecordListParams = {
|
||||
tenant_id: parsedInput.frdId,
|
||||
limit: parsedInput.limit ?? 50,
|
||||
};
|
||||
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
|
||||
if (parsedInput.sourceType) params.source_type = parsedInput.sourceType;
|
||||
if (parsedInput.fieldType) params.field_type = parsedInput.fieldType;
|
||||
if (parsedInput.since) params.since = parsedInput.since;
|
||||
if (parsedInput.until) params.until = parsedInput.until;
|
||||
|
||||
const result = await listFeedbackRecords(params);
|
||||
if (result.error || !result.data) {
|
||||
logger.warn({ error: result.error }, "Failed to list feedback records");
|
||||
throw new Error(result.error?.message ?? "Failed to load feedback records");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { importCsvData } from "./csv-import";
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./csv-transform", () => ({
|
||||
transformCsvRowsToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
|
||||
const { transformCsvRowsToFeedbackRecords } = vi.mocked(await import("./csv-transform"));
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorWithMappings => ({
|
||||
id: "conn-1",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "CSV Import",
|
||||
type: "csv",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [
|
||||
{
|
||||
id: "fm-1",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "feedback",
|
||||
targetFieldId: "value_text",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-2",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "",
|
||||
targetFieldId: "source_type",
|
||||
staticValue: "csv",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("importCsvData", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when no field mappings configured", async () => {
|
||||
const connector = makeConnector({ fieldMappings: [] });
|
||||
await expect(importCsvData(connector, [{ feedback: "test" }])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("returns zeros when all rows are skipped", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records: [], skipped: 3 });
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{ a: "1" }, { a: "2" }, { a: "3" }]);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 3 });
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends transformed records to Hub and counts results", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({
|
||||
records: [
|
||||
{ source_type: "csv", field_id: "q1", field_type: "text" as const, value_text: "Good" },
|
||||
{ source_type: "csv", field_id: "q2", field_type: "text" as const, value_text: "Bad" },
|
||||
],
|
||||
skipped: 1,
|
||||
});
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: null, error: { status: 400, message: "Bad request", detail: null } },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{}, {}, {}]);
|
||||
|
||||
expect(result).toEqual({ successes: 1, failures: 1, skipped: 1 });
|
||||
});
|
||||
|
||||
test("processes records in batches of 50", async () => {
|
||||
const records = Array.from({ length: 120 }, (_, i) => ({
|
||||
source_type: "csv",
|
||||
field_id: `q${i}`,
|
||||
field_type: "text" as const,
|
||||
value_text: `row ${i}`,
|
||||
}));
|
||||
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records, skipped: 0 });
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "fb" }, error: null }],
|
||||
} as never);
|
||||
|
||||
await importCsvData(
|
||||
makeConnector(),
|
||||
Array.from({ length: 120 }, () => ({}))
|
||||
);
|
||||
|
||||
expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(3);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[0][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[1][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[2][0]).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import "server-only";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
import { TImportResult } from "./import";
|
||||
|
||||
const CSV_BATCH_SIZE = 50;
|
||||
|
||||
export const importCsvData = async (
|
||||
connector: TConnectorWithMappings,
|
||||
csvRows: Record<string, string>[]
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "csv") {
|
||||
throw new InvalidInputError("CSV import is only supported for CSV connectors");
|
||||
}
|
||||
|
||||
if (connector.fieldMappings.length === 0) {
|
||||
throw new InvalidInputError("Connector has no field mappings configured");
|
||||
}
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
|
||||
for (let i = 0; i < records.length; i += CSV_BATCH_SIZE) {
|
||||
const batch = records.slice(i, i + CSV_BATCH_SIZE);
|
||||
const { results } = await createFeedbackRecordsBatch(batch);
|
||||
successes += results.filter((r) => r.data !== null).length;
|
||||
failures += results.filter((r) => r.error !== null).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped };
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeMapping = (
|
||||
sourceFieldId: string,
|
||||
targetFieldId: string,
|
||||
staticValue?: string
|
||||
): TConnectorFieldMapping => ({
|
||||
id: `mapping-${targetFieldId}`,
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId,
|
||||
targetFieldId: targetFieldId as TConnectorFieldMapping["targetFieldId"],
|
||||
staticValue: staticValue ?? null,
|
||||
});
|
||||
|
||||
const baseMappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
describe("transformCsvRowToFeedbackRecord", () => {
|
||||
test("transforms a basic row with all required fields", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source_type).toBe("survey");
|
||||
expect(result!.field_id).toBe("q1");
|
||||
expect(result!.field_type).toBe("text");
|
||||
expect(result!.value_text).toBe("Great product!");
|
||||
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
|
||||
test("returns null when required fields are missing", () => {
|
||||
const row = { feedback_text: "Great product!" };
|
||||
const mappings = [makeMapping("feedback_text", "value_text")];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("coerces value_number from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "4.5",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBe(4.5);
|
||||
});
|
||||
|
||||
test("skips value_number when not a valid number", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "not-a-number",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
test("coerces value_boolean from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("is_promoter", "value_boolean")];
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("handles $now static value for collected_at", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("", "collected_at", "$now"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
|
||||
expect(result!.collected_at).toBe(NOW.toISOString());
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("uses static value over source field", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("type_column", "source_type", "always_survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.source_type).toBe("always_survey");
|
||||
});
|
||||
|
||||
test("skips empty string values", () => {
|
||||
const row = {
|
||||
feedback_text: "",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.value_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses metadata as JSON", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: '{"device":"mobile","version":"2.1"}',
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
|
||||
});
|
||||
|
||||
test("wraps non-JSON metadata in { raw: value }", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: "just a string",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ raw: "just a string" });
|
||||
});
|
||||
|
||||
test("handles invalid date gracefully", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "not-a-date",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
test("transforms multiple rows and counts skipped", () => {
|
||||
const rows = [
|
||||
{ feedback_text: "Good", question: "q1", timestamp: "2026-01-15" },
|
||||
{ feedback_text: "Bad", question: "q2", timestamp: "2026-01-16" },
|
||||
{ feedback_text: "No question field" },
|
||||
];
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(skipped).toBe(1);
|
||||
expect(records[0].field_id).toBe("q1");
|
||||
expect(records[1].field_id).toBe("q2");
|
||||
});
|
||||
|
||||
test("returns empty records for empty input", () => {
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
|
||||
expect(records).toHaveLength(0);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
|
||||
import { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
|
||||
const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
|
||||
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
|
||||
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
|
||||
|
||||
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
|
||||
if (NUMERIC_FIELDS.has(targetField)) {
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
if (BOOLEAN_FIELDS.has(targetField)) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (TIMESTAMP_FIELDS.has(targetField)) {
|
||||
const date = new Date(trimmed);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveValue = (
|
||||
row: Record<string, string>,
|
||||
mapping: TConnectorFieldMapping
|
||||
): string | number | boolean | undefined => {
|
||||
if (mapping.staticValue) {
|
||||
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return coerceValue(mapping.staticValue, mapping.targetFieldId);
|
||||
}
|
||||
|
||||
const rawValue = row[mapping.sourceFieldId];
|
||||
if (rawValue === undefined || rawValue === null) return undefined;
|
||||
|
||||
return coerceValue(rawValue, mapping.targetFieldId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a single CSV row into a FeedbackRecord using field mappings.
|
||||
*
|
||||
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
|
||||
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
|
||||
*/
|
||||
export const transformCsvRowToFeedbackRecord = (
|
||||
row: Record<string, string>,
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): FeedbackRecordCreateParams | null => {
|
||||
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const value = resolveValue(row, mapping);
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (JSON_FIELDS.has(mapping.targetFieldId)) {
|
||||
try {
|
||||
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
record[mapping.targetFieldId] = { raw: value };
|
||||
}
|
||||
} else {
|
||||
record[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.source_type || !record.field_id || !record.field_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantId && !record.tenant_id) {
|
||||
record.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
return record as unknown as FeedbackRecordCreateParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform multiple CSV rows into FeedbackRecords.
|
||||
* Returns the successfully transformed records and a count of skipped rows.
|
||||
*/
|
||||
export const transformCsvRowsToFeedbackRecords = (
|
||||
rows: Record<string, string>[],
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): { records: FeedbackRecordCreateParams[]; skipped: number } => {
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const record = transformCsvRowToFeedbackRecord(row, mappings, tenantId);
|
||||
if (record) {
|
||||
records.push(record);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { records, skipped };
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { importHistoricalResponses } from "./import";
|
||||
|
||||
vi.mock("../response/service", () => ({
|
||||
getResponses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transform", () => ({
|
||||
transformResponseToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getResponses } = vi.mocked(await import("../response/service"));
|
||||
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
|
||||
const { transformResponseToFeedbackRecords } = vi.mocked(await import("./transform"));
|
||||
|
||||
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
|
||||
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
|
||||
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const mockConnector: TConnectorWithMappings = {
|
||||
id: CONNECTOR_ID,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "mapping-1",
|
||||
createdAt: NOW,
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
surveyId: SURVEY_ID,
|
||||
elementId: "el-1",
|
||||
hubFieldType: "text",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
const mockSurvey = { id: SURVEY_ID, blocks: [] } as unknown as TSurvey;
|
||||
|
||||
describe("importHistoricalResponses", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-formbricks connector", async () => {
|
||||
const csvConnector = { ...mockConnector, type: "csv" as const };
|
||||
|
||||
await expect(importHistoricalResponses(csvConnector, mockSurvey)).rejects.toThrow(InvalidInputError);
|
||||
expect(getResponses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns zeros when there are no responses", async () => {
|
||||
getResponses.mockResolvedValue([]);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
test("counts successes and skipped correctly", async () => {
|
||||
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords
|
||||
.mockReturnValueOnce([{ field: "record1" }] as never)
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([{ field: "record3" }] as never);
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: { id: "fb2" }, error: null },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result.successes).toBe(2);
|
||||
expect(result.failures).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
test("counts failures from Hub API errors", async () => {
|
||||
const mockResponses = [{ id: "r1" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: null, error: { status: 400, message: "Bad request" } }],
|
||||
} as never);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result.successes).toBe(0);
|
||||
expect(result.failures).toBe(1);
|
||||
});
|
||||
|
||||
test("paginates through responses in batches", async () => {
|
||||
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
|
||||
const batch2 = [{ id: "r50" }];
|
||||
|
||||
getResponses.mockResolvedValueOnce(batch1 as never);
|
||||
getResponses.mockResolvedValueOnce(batch2 as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "fb" }, error: null }],
|
||||
} as never);
|
||||
|
||||
await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 0);
|
||||
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 50);
|
||||
});
|
||||
|
||||
test("does not call Hub API when all responses are skipped", async () => {
|
||||
const mockResponses = [{ id: "r1" }, { id: "r2" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([]);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { getResponses } from "../response/service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
const IMPORT_BATCH_SIZE = 50;
|
||||
|
||||
export type TImportResult = { successes: number; failures: number; skipped: number };
|
||||
|
||||
const processBatch = async (
|
||||
responses: Awaited<ReturnType<typeof getResponses>>,
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId: string
|
||||
): Promise<TImportResult> => {
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
const expectedRecords = responses.length * mappings.length;
|
||||
|
||||
const allRecords = responses.flatMap((response) =>
|
||||
transformResponseToFeedbackRecords(response, survey, mappings, tenantId)
|
||||
);
|
||||
|
||||
if (allRecords.length > 0) {
|
||||
const { results } = await createFeedbackRecordsBatch(allRecords);
|
||||
successes = results.filter((r) => r.data !== null).length;
|
||||
failures = results.filter((r) => r.error !== null).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped: expectedRecords - allRecords.length };
|
||||
};
|
||||
|
||||
export const importHistoricalResponses = async (
|
||||
connector: TConnectorWithMappings,
|
||||
survey: TSurvey
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "formbricks") {
|
||||
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
|
||||
}
|
||||
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
let skipped = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const responses = await getResponses(survey.id, IMPORT_BATCH_SIZE, offset);
|
||||
if (responses.length === 0) break;
|
||||
|
||||
const batch = await processBatch(
|
||||
responses,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
successes += batch.successes;
|
||||
failures += batch.failures;
|
||||
skipped += batch.skipped;
|
||||
|
||||
if (responses.length < IMPORT_BATCH_SIZE) break;
|
||||
offset += IMPORT_BATCH_SIZE;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped };
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockCreateFeedbackRecordsBatch = vi.fn();
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: (...args: unknown[]) => mockCreateFeedbackRecordsBatch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
getConnectorsBySurveyId: vi.fn(),
|
||||
updateConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transform", () => ({
|
||||
transformResponseToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getConnectorsBySurveyId, updateConnector } = await import("./service");
|
||||
const { transformResponseToFeedbackRecords } = await import("./transform");
|
||||
const { handleConnectorPipeline } = await import("./pipeline-handler");
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: new Date("2026-02-24T10:00:00.000Z"),
|
||||
surveyId: "survey-1",
|
||||
data: { "el-1": "answer" },
|
||||
} as unknown as TResponse;
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
blocks: [{ id: "block-1", name: "Block", elements: [{ id: "el-1", headline: { default: "Question?" } }] }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
function createConnector(
|
||||
overrides: Partial<Pick<TConnectorWithMappings, "id" | "formbricksMappings">> = {}
|
||||
): TConnectorWithMappings {
|
||||
return {
|
||||
id: "conn-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackRecordDirectoryId: "frd-1",
|
||||
lastSyncAt: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "map-1",
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
elementId: "el-1",
|
||||
hubFieldType: "rating",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
...overrides,
|
||||
} as TConnectorWithMappings;
|
||||
}
|
||||
|
||||
const oneFeedbackRecord = [
|
||||
{
|
||||
field_id: "el-1",
|
||||
field_type: "rating" as const,
|
||||
source_type: "formbricks",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
value_number: 5,
|
||||
collected_at: "2026-02-24T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const noConfigError = {
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
};
|
||||
|
||||
describe("handleConnectorPipeline", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns early when no connectors for survey", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).not.toHaveBeenCalled();
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues when transform returns no feedback records", async () => {
|
||||
const connector = createConnector();
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([connector]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
connector.formbricksMappings,
|
||||
"frd-1"
|
||||
);
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not update connector when Hub returns no-config (HUB_API_KEY not set)", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: oneFeedbackRecord.map(() => ({ data: null, error: noConfigError })),
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends records to Hub and updates lastSyncAt on full success", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "hub-1", ...oneFeedbackRecord[0] }, error: null }],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not update connector when all Hub creates fail", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: null, error: { status: 500, message: "Hub unavailable", detail: "Hub unavailable" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates lastSyncAt on partial failure when some creates succeed", async () => {
|
||||
const twoRecords = [...oneFeedbackRecord, { ...oneFeedbackRecord[0], field_id: "el-2", value_number: 3 }];
|
||||
const baseMapping = {
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
hubFieldType: "rating" as const,
|
||||
customFieldLabel: null as string | null,
|
||||
};
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([
|
||||
createConnector({
|
||||
formbricksMappings: [
|
||||
{ ...baseMapping, id: "m1", elementId: "el-1" },
|
||||
{ ...baseMapping, id: "m2", elementId: "el-2" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(twoRecords as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "hub-1" }, error: null },
|
||||
{ data: null, error: { status: 429, message: "Rate limited", detail: "Rate limited" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not update connector when transform throws", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockImplementation(() => {
|
||||
throw new Error("Transform failed");
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { getConnectorsBySurveyId, updateConnector } from "./service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
const getErrorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
const logFailedRecords = (
|
||||
connectorId: string,
|
||||
results: Awaited<ReturnType<typeof createFeedbackRecordsBatch>>["results"]
|
||||
): void => {
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (!result.error) continue;
|
||||
logger.error(
|
||||
{
|
||||
connectorId,
|
||||
feedbackRecordIndex: index,
|
||||
error: {
|
||||
status: result.error.status,
|
||||
message: result.error.message,
|
||||
detail: result.error.detail,
|
||||
},
|
||||
},
|
||||
"Failed to create FeedbackRecord"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const processConnector = async (
|
||||
connector: TConnectorWithMappings,
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
workspaceId: string
|
||||
): Promise<void> => {
|
||||
const feedbackRecords = transformResponseToFeedbackRecords(
|
||||
response,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
if (feedbackRecords.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
|
||||
|
||||
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`
|
||||
);
|
||||
logFailedRecords(connector.id, results);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
feedbackRecordsCreated: successes,
|
||||
},
|
||||
`Connector pipeline: Successfully sent ${successes} FeedbackRecords`
|
||||
);
|
||||
}
|
||||
|
||||
if (successes > 0) {
|
||||
await updateConnector(connector.id, workspaceId, { lastSyncAt: new Date() });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param response - The survey response
|
||||
* @param survey - The survey
|
||||
* @param workspaceId - The workspace ID (used as tenant_id)
|
||||
*/
|
||||
export const handleConnectorPipeline = async (
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
workspaceId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const connectors = await getConnectorsBySurveyId(survey.id);
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const connector of connectors) {
|
||||
try {
|
||||
await processConnector(connector, response, survey, workspaceId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
"Connector pipeline: Failed to process connector"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
"Connector pipeline: Failed to handle connectors"
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,536 @@
|
||||
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 FRD_ID = "clxxxxxxxxxxxxxxxx004";
|
||||
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,
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
const mockConnectorWithMappingsFromDb = {
|
||||
...mockConnector,
|
||||
creator: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "mapping-1",
|
||||
createdAt: NOW,
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
surveyId: SURVEY_ID,
|
||||
elementId: "el-1",
|
||||
hubFieldType: "text",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
const mockConnectorWithMappings = {
|
||||
...mockConnector,
|
||||
creatorName: null,
|
||||
formbricksMappings: mockConnectorWithMappingsFromDb.formbricksMappings,
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
describe("getConnectorsWithMappings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns connectors for the given environment", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] as never);
|
||||
|
||||
const result = await getConnectorsWithMappings(ENV_ID);
|
||||
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workspaceId: 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([mockConnectorWithMappingsFromDb] 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, workspaceId: 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, workspaceId: 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, workspaceId: ENV_ID });
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_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, workspaceId: ENV_ID });
|
||||
tx.connectorFormbricksMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
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,
|
||||
workspaceId: 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, workspaceId: ENV_ID });
|
||||
tx.connectorFieldMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue({
|
||||
...mockConnector,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [],
|
||||
});
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "CSV", type: "csv", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
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,
|
||||
workspaceId: 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",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).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", feedbackRecordDirectoryId: FRD_ID })
|
||||
).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(mockConnectorWithMappingsFromDb);
|
||||
|
||||
const result = await updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Updated" });
|
||||
|
||||
expect(tx.connector.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CONNECTOR_ID, workspaceId: 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(mockConnectorWithMappingsFromDb);
|
||||
|
||||
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, workspaceId: 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, workspaceId: 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,369 @@
|
||||
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,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
creator: { select: { name: true } },
|
||||
formbricksMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
workspaceId: true,
|
||||
surveyId: true,
|
||||
elementId: true,
|
||||
hubFieldType: true,
|
||||
customFieldLabel: true,
|
||||
},
|
||||
},
|
||||
fieldMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
workspaceId: true,
|
||||
sourceFieldId: true,
|
||||
targetFieldId: true,
|
||||
staticValue: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
const selectConnector = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
type PrismaConnectorWithCreator = Prisma.ConnectorGetPayload<{ select: typeof selectConnectorWithMappings }>;
|
||||
|
||||
const mapConnectorWithMappings = (connector: PrismaConnectorWithCreator): TConnectorWithMappings => {
|
||||
const { creator, ...rest } = connector;
|
||||
return { ...rest, creatorName: creator?.name ?? null } as TConnectorWithMappings;
|
||||
};
|
||||
|
||||
export const getConnectorsWithMappings = reactCache(
|
||||
async (workspaceId: string, page?: number): Promise<TConnectorWithMappings[]> => {
|
||||
validateInputs([workspaceId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
select: selectConnectorWithMappings,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return connectors.map(mapConnectorWithMappings);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getConnectorWithMappingsById = reactCache(
|
||||
async (connectorId: string, workspaceId: string): Promise<TConnectorWithMappings | null> => {
|
||||
validateInputs([connectorId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
|
||||
return connector ? mapConnectorWithMappings(connector) : null;
|
||||
} 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.map(mapConnectorWithMappings);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateConnector = async (
|
||||
connectorId: string,
|
||||
workspaceId: string,
|
||||
data: TConnectorUpdateInput
|
||||
): Promise<TConnector> => {
|
||||
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.update({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
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, workspaceId: string): Promise<TConnector> => {
|
||||
validateInputs([connectorId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.delete({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
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 (
|
||||
workspaceId: string,
|
||||
data: TConnectorCreateInput,
|
||||
mappingsInput?: TMappingsInput
|
||||
): Promise<TConnectorWithMappings> => {
|
||||
validateInputs([workspaceId, ZId], [data, ZConnectorCreateInput]);
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const connector = await tx.connector.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId: connector.id,
|
||||
workspaceId,
|
||||
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,
|
||||
workspaceId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return tx.connector.findUniqueOrThrow({
|
||||
where: { id: connector.id },
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
});
|
||||
|
||||
return mapConnectorWithMappings(result);
|
||||
} 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,
|
||||
workspaceId: string,
|
||||
data: TConnectorUpdateInput,
|
||||
mappingsInput?: TMappingsInput
|
||||
): Promise<TConnectorWithMappings> => {
|
||||
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.connector.update({
|
||||
where: { id: connectorId, workspaceId },
|
||||
data: {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
lastSyncAt: data.lastSyncAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
await tx.connectorFormbricksMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId: mapping.surveyId,
|
||||
elementId: mapping.elementId,
|
||||
hubFieldType: mapping.hubFieldType,
|
||||
customFieldLabel: mapping.customFieldLabel,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} else if (mappingsInput?.type === "field") {
|
||||
await tx.connectorFieldMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFieldMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
workspaceId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return tx.connector.findUniqueOrThrow({
|
||||
where: { id: connectorId },
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
});
|
||||
|
||||
return mapConnectorWithMappings(result);
|
||||
} 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",
|
||||
workspaceId: "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,121 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
|
||||
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } 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 type { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
if (!element?.headline) return "Untitled";
|
||||
const raw = getLocalizedValue(element.headline, "default");
|
||||
return getTextContent(raw) || "Untitled";
|
||||
};
|
||||
|
||||
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
|
||||
if (!responseData || typeof responseData !== "object") return undefined;
|
||||
return responseData[elementId];
|
||||
}
|
||||
|
||||
const convertValueToHubFields = (
|
||||
value: TResponseDataValue,
|
||||
hubFieldType: THubFieldType
|
||||
): Partial<
|
||||
Pick<FeedbackRecordCreateParams, "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 FeedbackRecord payloads.
|
||||
* Called from the pipeline handler when a response is created/finished.
|
||||
*/
|
||||
export function transformResponseToFeedbackRecords(
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId: string
|
||||
): FeedbackRecordCreateParams[] {
|
||||
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: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
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 = {
|
||||
collected_at:
|
||||
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
|
||||
source_type: "formbricks",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
field_id: mapping.elementId,
|
||||
field_type: mapping.hubFieldType,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
field_label: fieldLabel,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_identifier: response.contact.userId } : {}),
|
||||
...valueFields,
|
||||
};
|
||||
|
||||
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
|
||||
}
|
||||
|
||||
return feedbackRecords;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const GITHUB_ID = env.GITHUB_ID;
|
||||
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;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getFormattedErrorMessage,
|
||||
getOrganizationIdFromActionClassId,
|
||||
getOrganizationIdFromApiKeyId,
|
||||
getOrganizationIdFromConnectorId,
|
||||
getOrganizationIdFromContactId,
|
||||
getOrganizationIdFromIntegrationId,
|
||||
getOrganizationIdFromInviteId,
|
||||
@@ -46,6 +47,7 @@ vi.mock("@/lib/utils/services", () => ({
|
||||
getLanguage: vi.fn(),
|
||||
getTeam: vi.fn(),
|
||||
getTag: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Helper Utilities", () => {
|
||||
@@ -326,6 +328,27 @@ describe("Helper Utilities", () => {
|
||||
const orgId = await getOrganizationIdFromQuotaId("quota1");
|
||||
expect(orgId).toBe("org1");
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromConnectorId returns organization ID through workspace", async () => {
|
||||
vi.mocked(services.getConnector).mockResolvedValueOnce({
|
||||
workspaceId: "workspace1",
|
||||
});
|
||||
vi.mocked(services.getWorkspace).mockResolvedValueOnce({
|
||||
organizationId: "org1",
|
||||
});
|
||||
|
||||
const orgId = await getOrganizationIdFromConnectorId("connector1");
|
||||
expect(orgId).toBe("org1");
|
||||
expect(services.getConnector).toHaveBeenCalledWith("connector1");
|
||||
expect(services.getWorkspace).toHaveBeenCalledWith("workspace1");
|
||||
});
|
||||
|
||||
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("Workspace ID retrieval functions", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
getActionClass,
|
||||
getApiKey,
|
||||
getConnector,
|
||||
getContact,
|
||||
getIntegration,
|
||||
getInvite,
|
||||
@@ -272,3 +273,13 @@ 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 getOrganizationIdFromWorkspaceId(connector.workspaceId);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
|
||||
import {
|
||||
getActionClass,
|
||||
getApiKey,
|
||||
getConnector,
|
||||
getContact,
|
||||
getIntegration,
|
||||
getInvite,
|
||||
@@ -89,6 +90,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 = { workspaceId: "ws123" };
|
||||
vi.mocked(prisma.connector.findUnique).mockResolvedValue(mockConnector);
|
||||
|
||||
const result = await getConnector(connectorId);
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(prisma.connector.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: connectorId },
|
||||
select: { workspaceId: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,3 +313,25 @@ export const getSegment = reactCache(async (segmentId: string): Promise<{ worksp
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getConnector = reactCache(
|
||||
async (connectorId: string): Promise<{ workspaceId: string } | null> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
return connector;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
+1719
-1596
File diff suppressed because it is too large
Load Diff
+125
-2
@@ -167,7 +167,7 @@
|
||||
"code": "Code",
|
||||
"collapse_rows": "Collapse rows",
|
||||
"completed": "Completed",
|
||||
"configuration": "Configuration",
|
||||
"configuration": "Configure",
|
||||
"confirm": "Confirm",
|
||||
"connect": "Connect",
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
|
||||
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
|
||||
"create": "Create",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"enable": "Enable",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"failed_to_parse_csv": "Failed to parse CSV",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no": "No",
|
||||
"no_actions_found": "No actions found",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_code": "No code",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "Response ID",
|
||||
"responses": "Responses",
|
||||
"restart": "Restart",
|
||||
"retry": "Retry",
|
||||
"role": "Role",
|
||||
"saas": "SaaS",
|
||||
"sales": "Sales",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 day left in your trial",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unify": "Unify",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
"view": "View",
|
||||
"warning": "Warning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "e.g. Formbricks",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"yes": "Yes",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "You are not allowed to archive this directory.",
|
||||
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
|
||||
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
|
||||
"connectors_description": "Connectors that send feedback records to this directory.",
|
||||
"create_feedback_directory": "Create feedback directory",
|
||||
"description": "Manage feedback record directories and their workspace assignments.",
|
||||
"directory_archived_successfully": "Directory archived successfully",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Directory unarchived successfully",
|
||||
"directory_updated_successfully": "Directory updated successfully",
|
||||
"empty_state": "No feedback record directories found. Create one to get started.",
|
||||
"enter_directory_name": "Enter directory name",
|
||||
"error_directory_has_connectors": "Cannot archive a directory that has connectors linked to it. Remove all connectors first.",
|
||||
"error_directory_name_duplicate": "A feedback record directory with this name already exists.",
|
||||
"error_directory_name_required": "Directory name is required.",
|
||||
"error_directory_workspaces_invalid_org": "Some specified workspaces do not belong to this organization.",
|
||||
"nav_label": "Feedback Directories",
|
||||
"no_access": "You do not have permission to manage feedback record directories.",
|
||||
"no_connectors": "No connectors linked to this directory yet.",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Team Name",
|
||||
"team_settings_description": "See which teams can access this workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Add Feedback Source",
|
||||
"add_source": "Add source",
|
||||
"allowed_values": "Allowed values: {values}",
|
||||
"change_file": "Change file",
|
||||
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
|
||||
"click_to_upload": "Click to upload",
|
||||
"collected_at": "Collected At",
|
||||
"configure_import": "Configure import",
|
||||
"configure_mapping": "Configure Mapping",
|
||||
"connection": "Connection",
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
"connector_duplicated_successfully": "Connector duplicated successfully",
|
||||
"connector_status_updated_successfully": "Connector status updated successfully",
|
||||
"connector_updated_successfully": "Connector updated successfully",
|
||||
"connectors": "Connectors",
|
||||
"create_mapping": "Create mapping",
|
||||
"created_by": "Created by",
|
||||
"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_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
|
||||
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
|
||||
"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",
|
||||
"deselect_all": "Deselect all",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"edit_csv_mapping": "Edit CSV mapping",
|
||||
"edit_source_connection": "Edit Source Connection",
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_record_directory": "Feedback Record Directory",
|
||||
"feedback_record_fields": "Feedback Record Fields",
|
||||
"feedback_records": "Feedback Records",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"field_label": "Field Label",
|
||||
"field_type": "Field Type",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
|
||||
"go_to_feedback_record_directories": "Go to directories settings",
|
||||
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
|
||||
"import_csv_data": "Import feedback",
|
||||
"import_feedback": "Import feedback",
|
||||
"import_rows": "Import {count} rows",
|
||||
"importing_data": "Importing data...",
|
||||
"importing_historical_data": "Importing historical data...",
|
||||
"invalid_enum_values": "Invalid values in column mapped to {field}",
|
||||
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
|
||||
"load_sample_csv": "Load sample CSV",
|
||||
"n_supported_questions": "{count} supported questions",
|
||||
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"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_drag_and_drop": "or drag and drop",
|
||||
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
|
||||
"question_type_not_supported": "This question type is not supported",
|
||||
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
|
||||
"records_will_go_to": "Records will go to",
|
||||
"refresh_feedback_records": "Refresh feedback records",
|
||||
"refreshing_feedback_records": "Refreshing feedback records...",
|
||||
"required": "Required",
|
||||
"save_changes": "Save changes",
|
||||
"select_a_survey_to_see_questions": "Select a survey to see its questions",
|
||||
"select_a_value": "Select a value...",
|
||||
"select_all": "Select all",
|
||||
"select_feedback_record_directory": "Select a directory",
|
||||
"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_count_loaded": "Showing {count} records",
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"source": "source",
|
||||
"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": "Source Type",
|
||||
"source_type_cannot_be_changed": "Source type cannot be changed",
|
||||
"sources": "Sources",
|
||||
"status_active": "In Progress",
|
||||
"status_completed": "Completed",
|
||||
"status_draft": "Draft",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Paused",
|
||||
"survey_has_no_questions": "This survey has no questions",
|
||||
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
"updated_at": "Updated at",
|
||||
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"user_identifier": "User",
|
||||
"value": "Value"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Leverage every touchpoint to understand ease of customer interaction.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pregunta} other {{count} preguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
|
||||
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
|
||||
"create": "Crear",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Activar",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
"enterprise_license": "Licencia empresarial",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"failed_to_parse_csv": "Error al analizar el CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Nuevo",
|
||||
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
|
||||
"next": "Siguiente",
|
||||
"no": "No",
|
||||
"no_actions_found": "No se encontraron acciones",
|
||||
"no_background_image_found": "No se encontró imagen de fondo.",
|
||||
"no_code": "Sin código",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID de respuesta",
|
||||
"responses": "Respuestas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Reintentar",
|
||||
"role": "Rol",
|
||||
"saas": "SaaS",
|
||||
"sales": "Ventas",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 día restante en tu prueba",
|
||||
"try_again": "Intentar de nuevo",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Encuesta desconocida",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
|
||||
"update": "Actualizar",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Correo electrónico verificado",
|
||||
"video": "Vídeo",
|
||||
"view": "Ver",
|
||||
"warning": "Advertencia",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "p. ej. Formbricks",
|
||||
"workspaces": "Proyectos",
|
||||
"years": "años",
|
||||
"yes": "Sí",
|
||||
"you": "Tú",
|
||||
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "No tienes permiso para archivar este directorio.",
|
||||
"are_you_sure_you_want_to_archive": "¿Estás seguro de que quieres archivar este directorio? Los espacios de trabajo ya no tendrán acceso a él.",
|
||||
"assign_workspaces_description": "Controla qué espacios de trabajo pueden acceder a este directorio de registros de feedback.",
|
||||
"connectors_description": "Conectores que envían registros de comentarios a este directorio.",
|
||||
"create_feedback_directory": "Crear directorio de comentarios",
|
||||
"description": "Gestiona los directorios de registros de feedback y sus asignaciones de espacios de trabajo.",
|
||||
"directory_archived_successfully": "Directorio archivado correctamente",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Directorio desarchivado correctamente",
|
||||
"directory_updated_successfully": "Directorio actualizado correctamente",
|
||||
"empty_state": "No se encontraron directorios de registros de feedback. Crea uno para empezar.",
|
||||
"enter_directory_name": "Introduce el nombre del directorio",
|
||||
"error_directory_has_connectors": "No se puede archivar un directorio que tiene conectores vinculados. Elimina primero todos los conectores.",
|
||||
"error_directory_name_duplicate": "Ya existe un directorio de registros de comentarios con este nombre.",
|
||||
"error_directory_name_required": "El nombre del directorio es obligatorio.",
|
||||
"error_directory_workspaces_invalid_org": "Algunos de los espacios de trabajo especificados no pertenecen a esta organización.",
|
||||
"nav_label": "Directorios de Feedback",
|
||||
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
|
||||
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"title": "Directorios de Registros de Feedback",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Añadir fuente de feedback",
|
||||
"add_source": "Añadir fuente",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"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",
|
||||
"collected_at": "Recopilado el",
|
||||
"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_duplicated_successfully": "Conector duplicado correctamente",
|
||||
"connector_status_updated_successfully": "Estado del conector actualizado correctamente",
|
||||
"connector_updated_successfully": "Conector actualizado correctamente",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Crear asignación",
|
||||
"created_by": "Creado por",
|
||||
"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_import_complete": "Importación de CSV completada: {successes} correctas, {failures} fallidas, {skipped} omitidas",
|
||||
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
|
||||
"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",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"edit_csv_mapping": "Editar mapeo de CSV",
|
||||
"edit_source_connection": "Editar conexión de origen",
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_record_directory": "Directorio de Registros de Comentarios",
|
||||
"feedback_record_fields": "Campos de registro de comentarios",
|
||||
"feedback_records": "Registros de comentarios",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"field_label": "Etiqueta de campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "El directorio de comentarios no se puede cambiar después de su creación.",
|
||||
"go_to_feedback_record_directories": "Ir a la configuración de directorios",
|
||||
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
|
||||
"import_csv_data": "Importar comentarios",
|
||||
"import_feedback": "Importar comentarios",
|
||||
"import_rows": "Importar {count} filas",
|
||||
"importing_data": "Importando datos...",
|
||||
"importing_historical_data": "Importando datos históricos...",
|
||||
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
|
||||
"load_sample_csv": "Cargar CSV de muestra",
|
||||
"n_supported_questions": "{count} preguntas compatibles",
|
||||
"no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"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_drag_and_drop": "o arrastra y suelta",
|
||||
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
|
||||
"question_type_not_supported": "Este tipo de pregunta no es compatible",
|
||||
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
|
||||
"records_will_go_to": "Los registros se enviarán a",
|
||||
"refresh_feedback_records": "Actualizar los registros de comentarios",
|
||||
"refreshing_feedback_records": "Actualizando registros de comentarios...",
|
||||
"required": "Obligatorio",
|
||||
"save_changes": "Guardar cambios",
|
||||
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
|
||||
"select_a_value": "Selecciona un valor...",
|
||||
"select_all": "Seleccionar todo",
|
||||
"select_feedback_record_directory": "Selecciona un directorio",
|
||||
"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_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} filas",
|
||||
"source": "origen",
|
||||
"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": "Tipo de fuente",
|
||||
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
|
||||
"sources": "Orígenes",
|
||||
"status_active": "En progreso",
|
||||
"status_completed": "Completado",
|
||||
"status_draft": "Borrador",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Pausado",
|
||||
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
|
||||
"updated_at": "Actualizado el",
|
||||
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
|
||||
"upload_csv_file": "Subir archivo CSV",
|
||||
"user_identifier": "Usuario",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aprovecha cada punto de contacto para entender la facilidad de interacción del cliente.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, one {{count} réponse} other {{count} réponses}}",
|
||||
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
|
||||
"create": "Créer",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"enable": "Activer",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"failed_to_parse_csv": "Échec de l'analyse du CSV",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Nouveau",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"no": "Non",
|
||||
"no_actions_found": "Aucune action trouvée",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_code": "Sans code",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID de réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Recommencer",
|
||||
"retry": "Réessayer",
|
||||
"role": "Rôle",
|
||||
"saas": "SaaS",
|
||||
"sales": "Ventes",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unify": "Unifier",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
|
||||
"update": "Mise à jour",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Email vérifié",
|
||||
"video": "Vidéo",
|
||||
"view": "Afficher",
|
||||
"warning": "Avertissement",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "par ex. Formbricks",
|
||||
"workspaces": "Projets",
|
||||
"years": "années",
|
||||
"yes": "Oui",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Vous n'êtes pas autorisé à archiver ce répertoire.",
|
||||
"are_you_sure_you_want_to_archive": "Es-tu sûr de vouloir archiver ce répertoire ? Les espaces de travail n'y auront plus accès.",
|
||||
"assign_workspaces_description": "Contrôle quels espaces de travail peuvent accéder à ce répertoire de feedback.",
|
||||
"connectors_description": "Connecteurs qui envoient des enregistrements de retour d'expérience vers ce répertoire.",
|
||||
"create_feedback_directory": "Créer un répertoire de commentaires",
|
||||
"description": "Gère les répertoires de feedback et leurs affectations aux espaces de travail.",
|
||||
"directory_archived_successfully": "Répertoire archivé avec succès",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Répertoire désarchivé avec succès",
|
||||
"directory_updated_successfully": "Répertoire mis à jour avec succès",
|
||||
"empty_state": "Aucun répertoire de feedback trouvé. Crée-en un pour commencer.",
|
||||
"enter_directory_name": "Saisir le nom du répertoire",
|
||||
"error_directory_has_connectors": "Impossible d'archiver un répertoire auquel des connecteurs sont liés. Supprimez d'abord tous les connecteurs.",
|
||||
"error_directory_name_duplicate": "Un répertoire d'enregistrement de feedback avec ce nom existe déjà.",
|
||||
"error_directory_name_required": "Le nom du répertoire est requis.",
|
||||
"error_directory_workspaces_invalid_org": "Certains espaces de travail spécifiés n'appartiennent pas à cette organisation.",
|
||||
"nav_label": "Répertoires de feedback",
|
||||
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
|
||||
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
"show_archived": "Afficher les éléments archivés",
|
||||
"title": "Répertoires d'enregistrement des retours",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Ajouter une source de feedback",
|
||||
"add_source": "Ajouter une source",
|
||||
"allowed_values": "Valeurs autorisées : {values}",
|
||||
"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",
|
||||
"collected_at": "Collecté le",
|
||||
"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_duplicated_successfully": "Connecteur dupliqué avec succès",
|
||||
"connector_status_updated_successfully": "Statut du connecteur mis à jour avec succès",
|
||||
"connector_updated_successfully": "Connecteur mis à jour avec succès",
|
||||
"connectors": "Connecteurs",
|
||||
"create_mapping": "Créer un mappage",
|
||||
"created_by": "Créé par",
|
||||
"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_import_complete": "Importation CSV terminée : {successes} réussies, {failures} échouées, {skipped} ignorées",
|
||||
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
|
||||
"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",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"edit_csv_mapping": "Modifier le mappage CSV",
|
||||
"edit_source_connection": "Modifier la connexion source",
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience",
|
||||
"feedback_record_fields": "Champs d'enregistrement de feedback",
|
||||
"feedback_records": "Enregistrements de feedback",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"field_label": "Libellé du champ",
|
||||
"field_type": "Type de champ",
|
||||
"formbricks_surveys": "Sondages Formbricks",
|
||||
"frd_cannot_be_changed": "Le répertoire de retours d'expérience ne peut pas être modifié après sa création.",
|
||||
"go_to_feedback_record_directories": "Accéder aux paramètres des répertoires",
|
||||
"historical_import_complete": "Importation terminée : {successes} réussies, {failures} échouées, {skipped} ignorées (aucune donnée)",
|
||||
"import_csv_data": "Importer les retours",
|
||||
"import_feedback": "Importer les retours",
|
||||
"import_rows": "Importer {count} lignes",
|
||||
"importing_data": "Importation des données...",
|
||||
"importing_historical_data": "Importation des données historiques...",
|
||||
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
|
||||
"invalid_values_found": "Trouvées : {values} (lignes : {rows}) {extra}",
|
||||
"load_sample_csv": "Charger un exemple de CSV",
|
||||
"n_supported_questions": "{count} questions prises en charge",
|
||||
"no_feedback_record_directory_available": "Aucun répertoire d'enregistrements de retour d'expérience n'est assigné à cet espace de travail. Créez-en un ou assignez-en un d'abord.",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"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_drag_and_drop": "ou glisser-déposer",
|
||||
"question_selected": "<strong>{count}</strong> question sélectionnée. Chaque réponse à cette question créera un nouvel enregistrement de feedback.",
|
||||
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
|
||||
"questions_selected": "<strong>{count}</strong> questions sélectionnées. Chaque réponse à ces questions créera un nouvel enregistrement de feedback.",
|
||||
"records_will_go_to": "Les enregistrements seront envoyés vers",
|
||||
"refresh_feedback_records": "Actualiser les enregistrements de retours",
|
||||
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
|
||||
"required": "Requis",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
|
||||
"select_a_value": "Sélectionnez une valeur...",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_feedback_record_directory": "Sélectionner un répertoire",
|
||||
"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_count_loaded": "Affichage de {count} enregistrements",
|
||||
"showing_rows": "Affichage de 3 sur {count} lignes",
|
||||
"source": "source",
|
||||
"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": "Type de source",
|
||||
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
|
||||
"sources": "Sources",
|
||||
"status_active": "En cours",
|
||||
"status_completed": "Terminé",
|
||||
"status_draft": "Brouillon",
|
||||
"status_error": "Erreur",
|
||||
"status_paused": "En pause",
|
||||
"survey_has_no_questions": "Ce sondage n'a pas de questions",
|
||||
"survey_import_line": "{surveyName} : {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
|
||||
"total_feedback_records": "Total : {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
|
||||
"unify_feedback": "Unifier les retours",
|
||||
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.",
|
||||
"upload_csv_file": "Télécharger un fichier CSV",
|
||||
"user_identifier": "Utilisateur",
|
||||
"value": "Valeur"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Tirez parti de chaque point de contact pour comprendre la facilité d'interaction avec le client.",
|
||||
|
||||
+125
-2
@@ -167,7 +167,7 @@
|
||||
"code": "Kód",
|
||||
"collapse_rows": "Sorok összecsukása",
|
||||
"completed": "Befejezve",
|
||||
"configuration": "Beállítás",
|
||||
"configuration": "Konfiguráció",
|
||||
"confirm": "Megerősítés",
|
||||
"connect": "Kapcsolódás",
|
||||
"connect_formbricks": "Kapcsolódás a Formbrickshez",
|
||||
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
|
||||
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
|
||||
"count_selections": "{count, plural, one {{count} kiválasztás} other {{count} kiválasztás}}",
|
||||
"create": "Létrehozás",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"enable": "Engedélyezés",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
"enterprise_license": "Vállalati licenc",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"failed_to_parse_csv": "A CSV elemzése sikertelen",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Új",
|
||||
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
|
||||
"next": "Következő",
|
||||
"no": "Nem",
|
||||
"no_actions_found": "Nem találhatók műveletek",
|
||||
"no_background_image_found": "Nem található háttérkép.",
|
||||
"no_code": "Kód nélkül",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "Válaszazonosító",
|
||||
"responses": "Válaszok",
|
||||
"restart": "Újraindítás",
|
||||
"retry": "Újra",
|
||||
"role": "Szerep",
|
||||
"saas": "SaaS",
|
||||
"sales": "Értékesítés",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unify": "Egyesítés",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
|
||||
"update": "Frissítés",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Változók",
|
||||
"verified_email": "Ellenőrzött e-mail-cím",
|
||||
"video": "Videó",
|
||||
"view": "Megtekintés",
|
||||
"warning": "Figyelmeztetés",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.",
|
||||
"webhook": "Webhorog",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "például Formbricks",
|
||||
"workspaces": "Munkaterületek",
|
||||
"years": "év",
|
||||
"yes": "Igen",
|
||||
"you": "Ön",
|
||||
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Nem rendelkezik jogosultsággal ezen könyvtár archiválásához.",
|
||||
"are_you_sure_you_want_to_archive": "Biztosan archiválni kívánja ezt a könyvtárat? A munkaterületek többé nem férhetnek hozzá.",
|
||||
"assign_workspaces_description": "Szabályozza, mely munkaterületek férhetnek hozzá ehhez a visszajelzési nyilvántartási könyvtárhoz.",
|
||||
"connectors_description": "Csatlakozók, amelyek visszajelzési rekordokat küldenek ebbe a könyvtárba.",
|
||||
"create_feedback_directory": "Visszajelzési könyvtár létrehozása",
|
||||
"description": "Visszajelzési nyilvántartási könyvtárak és munkaterület-hozzárendeléseik kezelése.",
|
||||
"directory_archived_successfully": "A könyvtár sikeresen archiválva",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "A könyvtár archiválása sikeresen visszavonva",
|
||||
"directory_updated_successfully": "A könyvtár sikeresen frissítve",
|
||||
"empty_state": "Nem található visszajelzési nyilvántartási könyvtár. Hozzon létre egyet a kezdéshez.",
|
||||
"enter_directory_name": "Adja meg a könyvtár nevét",
|
||||
"error_directory_has_connectors": "Nem archiválható olyan könyvtár, amelyhez csatlakozók vannak társítva. Először távolítson el minden csatlakozót.",
|
||||
"error_directory_name_duplicate": "Ezzel a névvel már létezik visszajelzési rekord könyvtár.",
|
||||
"error_directory_name_required": "A könyvtár neve kötelező megadni.",
|
||||
"error_directory_workspaces_invalid_org": "Egyes megadott munkaterületek nem ehhez a szervezethez tartoznak.",
|
||||
"nav_label": "Visszajelzési könyvtárak",
|
||||
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
|
||||
"no_connectors": "Még nincsenek csatlakozók társítva ehhez a könyvtárhoz.",
|
||||
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
|
||||
"show_archived": "Archivált elemek megjelenítése",
|
||||
"title": "Visszajelzési Nyilvántartási Könyvtárak",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Visszajelzési forrás hozzáadása",
|
||||
"add_source": "Forrás hozzáadása",
|
||||
"allowed_values": "Engedélyezett értékek: {values}",
|
||||
"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",
|
||||
"collected_at": "Gyűjtve",
|
||||
"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_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
|
||||
"connector_status_updated_successfully": "Csatlakozó állapota sikeresen frissítve",
|
||||
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
|
||||
"connectors": "Csatlakozók",
|
||||
"create_mapping": "Leképezés létrehozása",
|
||||
"created_by": "Létrehozta",
|
||||
"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_import_complete": "CSV importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva",
|
||||
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
|
||||
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
|
||||
"csv_max_records": "Maximum {max} rekord engedélyezett.",
|
||||
"default_connector_name_csv": "CSV importálás",
|
||||
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
|
||||
"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",
|
||||
"edit_csv_mapping": "CSV leképezés szerkesztése",
|
||||
"edit_source_connection": "Forráskapcsolat szerkesztése",
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"enum": "felsorolás",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_record_directory": "Visszajelzési Rekord Könyvtár",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"field_label": "Mező címke",
|
||||
"field_type": "Mező típus",
|
||||
"formbricks_surveys": "Formbricks kérdőívek",
|
||||
"frd_cannot_be_changed": "A visszajelzési könyvtár a létrehozás után nem módosítható.",
|
||||
"go_to_feedback_record_directories": "Ugrás a könyvtárbeállításokhoz",
|
||||
"historical_import_complete": "Importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva (nincs adat)",
|
||||
"import_csv_data": "Visszajelzés importálása",
|
||||
"import_feedback": "Visszajelzés importálása",
|
||||
"import_rows": "{count} sor importálása",
|
||||
"importing_data": "Adatok importálása...",
|
||||
"importing_historical_data": "Történeti adatok importálása...",
|
||||
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
|
||||
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
|
||||
"load_sample_csv": "Minta CSV betöltése",
|
||||
"n_supported_questions": "{count} támogatott kérdés",
|
||||
"no_feedback_record_directory_available": "Ehhez a munkaterülethez nem tartozik visszajelzési rekord könyvtár. Először hozzon létre vagy rendeljen hozzá egyet.",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"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_drag_and_drop": "vagy húzd ide",
|
||||
"question_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
|
||||
"questions_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"records_will_go_to": "A rekordok ide kerülnek",
|
||||
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
|
||||
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
|
||||
"required": "Kötelező",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
|
||||
"select_a_value": "Válassz egy értéket...",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"select_feedback_record_directory": "Válasszon egy könyvtárat",
|
||||
"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_count_loaded": "{count} rekord megjelenítése",
|
||||
"showing_rows": "3 megjelenítve {count} sorból",
|
||||
"source": "forrás",
|
||||
"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": "Forrás típus",
|
||||
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
|
||||
"sources": "Források",
|
||||
"status_active": "Folyamatban",
|
||||
"status_completed": "Befejezve",
|
||||
"status_draft": "Piszkozat",
|
||||
"status_error": "Hiba",
|
||||
"status_paused": "Szüneteltetve",
|
||||
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
|
||||
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
|
||||
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
|
||||
"unify_feedback": "Visszajelzések egyesítése",
|
||||
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
|
||||
"updated_at": "Frissítve",
|
||||
"upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.",
|
||||
"upload_csv_file": "CSV fájl feltöltése",
|
||||
"user_identifier": "Felhasználó",
|
||||
"value": "Érték"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Minden érintkezési pont kihasználása az ügyfelekkel való interakció egyszerűségének megértéséhez.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, other {# 件の質問}}",
|
||||
"count_responses": "{count, plural, other {{count} 件の回答}}",
|
||||
"count_selections": "{count, plural, other {{count} 件の選択}}",
|
||||
"create": "作成",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
"create_survey": "フォームを作成",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"enable": "有効化",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"failed_to_parse_csv": "CSVの解析に失敗しました",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "新規",
|
||||
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
|
||||
"next": "次へ",
|
||||
"no": "いいえ",
|
||||
"no_actions_found": "アクションが見つかりません",
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_code": "ノーコード",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"retry": "再試行",
|
||||
"role": "役割",
|
||||
"saas": "SaaS",
|
||||
"sales": "セールス",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "トライアル期間の残り1日",
|
||||
"try_again": "もう一度お試しください",
|
||||
"type": "種類",
|
||||
"unify": "統合",
|
||||
"unknown_survey": "不明なフォーム",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
"view": "表示",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "例: Formbricks",
|
||||
"workspaces": "ワークスペース",
|
||||
"years": "年",
|
||||
"yes": "はい",
|
||||
"you": "あなた",
|
||||
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
|
||||
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "このディレクトリをアーカイブする権限がありません。",
|
||||
"are_you_sure_you_want_to_archive": "このディレクトリをアーカイブしてもよろしいですか?ワークスペースはアクセスできなくなります。",
|
||||
"assign_workspaces_description": "このフィードバック記録ディレクトリにアクセスできるワークスペースを管理します。",
|
||||
"connectors_description": "このディレクトリにフィードバックレコードを送信するコネクタ。",
|
||||
"create_feedback_directory": "フィードバックディレクトリを作成",
|
||||
"description": "フィードバック記録ディレクトリとワークスペースの割り当てを管理します。",
|
||||
"directory_archived_successfully": "ディレクトリをアーカイブしました",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "ディレクトリのアーカイブを解除しました",
|
||||
"directory_updated_successfully": "ディレクトリを更新しました",
|
||||
"empty_state": "フィードバック記録ディレクトリが見つかりません。最初のディレクトリを作成してください。",
|
||||
"enter_directory_name": "ディレクトリ名を入力してください",
|
||||
"error_directory_has_connectors": "コネクタがリンクされているディレクトリはアーカイブできません。まずすべてのコネクタを削除してください。",
|
||||
"error_directory_name_duplicate": "この名前のフィードバック記録ディレクトリは既に存在します。",
|
||||
"error_directory_name_required": "ディレクトリ名は必須です。",
|
||||
"error_directory_workspaces_invalid_org": "指定されたワークスペースの一部がこの組織に属していません。",
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
|
||||
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
"show_archived": "アーカイブ済みを表示",
|
||||
"title": "フィードバック記録ディレクトリ",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "チーム名",
|
||||
"team_settings_description": "このワークスペースにアクセスできるチームを確認します。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "フィードバックソースを追加",
|
||||
"add_source": "ソースを追加",
|
||||
"allowed_values": "許可される値: {values}",
|
||||
"change_file": "ファイルを変更",
|
||||
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"collected_at": "収集日時",
|
||||
"configure_import": "インポートを設定",
|
||||
"configure_mapping": "マッピングを設定",
|
||||
"connection": "接続",
|
||||
"connector_created_successfully": "コネクタが正常に作成されました",
|
||||
"connector_deleted_successfully": "コネクタが正常に削除されました",
|
||||
"connector_duplicated_successfully": "コネクタが正常に複製されました",
|
||||
"connector_status_updated_successfully": "コネクタのステータスが正常に更新されました",
|
||||
"connector_updated_successfully": "コネクタが正常に更新されました",
|
||||
"connectors": "コネクタ",
|
||||
"create_mapping": "マッピングを作成",
|
||||
"created_by": "作成者",
|
||||
"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_import_complete": "CSVインポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ",
|
||||
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
|
||||
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
|
||||
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"deselect_all": "すべて選択解除",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"edit_csv_mapping": "CSVマッピングを編集",
|
||||
"edit_source_connection": "ソース接続を編集",
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_record_directory": "フィードバックレコードディレクトリ",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
"feedback_records": "フィードバックレコード",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"field_label": "フィールドラベル",
|
||||
"field_type": "フィールドタイプ",
|
||||
"formbricks_surveys": "Formbricks フォーム",
|
||||
"frd_cannot_be_changed": "フィードバックディレクトリは作成後に変更できません。",
|
||||
"go_to_feedback_record_directories": "ディレクトリ設定へ移動",
|
||||
"historical_import_complete": "インポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ(データなし)",
|
||||
"import_csv_data": "フィードバックをインポート",
|
||||
"import_feedback": "フィードバックをインポート",
|
||||
"import_rows": "{count}行をインポート",
|
||||
"importing_data": "データをインポート中...",
|
||||
"importing_historical_data": "過去のデータをインポート中...",
|
||||
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
|
||||
"invalid_values_found": "検出された値: {values}(行: {rows}){extra}",
|
||||
"load_sample_csv": "サンプルCSVを読み込む",
|
||||
"n_supported_questions": "{count} 件のサポートされている質問",
|
||||
"no_feedback_record_directory_available": "このワークスペースにフィードバックレコードディレクトリが割り当てられていません。まず作成または割り当てを行ってください。",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"no_surveys_found": "この環境にフォームが見つかりません",
|
||||
"optional": "任意",
|
||||
"or_drag_and_drop": "またはドラッグ&ドロップ",
|
||||
"question_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"question_type_not_supported": "この質問タイプはサポートされていません",
|
||||
"questions_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"records_will_go_to": "レコードの保存先",
|
||||
"refresh_feedback_records": "フィードバック記録を更新",
|
||||
"refreshing_feedback_records": "フィードバックレコードを更新中...",
|
||||
"required": "必須",
|
||||
"save_changes": "変更を保存",
|
||||
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
|
||||
"select_a_value": "値を選択...",
|
||||
"select_all": "すべて選択",
|
||||
"select_feedback_record_directory": "ディレクトリを選択",
|
||||
"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_count_loaded": "{count}件のレコードを表示中",
|
||||
"showing_rows": "{count}行中3行を表示",
|
||||
"source": "ソース",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_name": "ソース名",
|
||||
"source_type": "ソースタイプ",
|
||||
"source_type_cannot_be_changed": "ソースタイプは変更できません",
|
||||
"sources": "ソース",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "完了",
|
||||
"status_draft": "下書き",
|
||||
"status_error": "エラー",
|
||||
"status_paused": "一時停止",
|
||||
"survey_has_no_questions": "このアンケートには質問がありません",
|
||||
"survey_import_line": "{surveyName}: {responseCount}件の回答 × {questionCount}件の質問 = {total}件のフィードバックレコード",
|
||||
"total_feedback_records": "合計: {surveyCount}件のアンケート全体で{total}件中{checked}件のフィードバックレコードが選択されています",
|
||||
"unify_feedback": "フィードバックを統合",
|
||||
"update_mapping_description": "このソースのマッピング設定を更新します。",
|
||||
"updated_at": "更新日時",
|
||||
"upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをインポートします。",
|
||||
"upload_csv_file": "CSVファイルをアップロード",
|
||||
"user_identifier": "ユーザー",
|
||||
"value": "値"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "あらゆるタッチポイントを活用して、顧客のインタラクションの容易さを把握します。",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} vraag} other {{count} vragen}}",
|
||||
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
|
||||
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
|
||||
"create": "Aanmaken",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"enable": "Inschakelen",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
"enterprise_license": "Enterprise-licentie",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||
"failed_to_parse_csv": "Kan CSV niet verwerken",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "Voornaam",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Nieuw",
|
||||
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
|
||||
"next": "Volgende",
|
||||
"no": "Nee",
|
||||
"no_actions_found": "Geen acties gevonden",
|
||||
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
|
||||
"no_code": "Geen code",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "Antwoord-ID",
|
||||
"responses": "Reacties",
|
||||
"restart": "Opnieuw opstarten",
|
||||
"retry": "Opnieuw proberen",
|
||||
"role": "Rol",
|
||||
"saas": "SaaS",
|
||||
"sales": "Verkoop",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 dag over in je proefperiode",
|
||||
"try_again": "Probeer het opnieuw",
|
||||
"type": "Type",
|
||||
"unify": "Verenigen",
|
||||
"unknown_survey": "Onbekende enquête",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
|
||||
"update": "Update",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variabelen",
|
||||
"verified_email": "Geverifieerde e-mail",
|
||||
"video": "Video",
|
||||
"view": "Weergeven",
|
||||
"warning": "Waarschuwing",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "bijv. Formbricks",
|
||||
"workspaces": "Werkruimtes",
|
||||
"years": "jaren",
|
||||
"yes": "Ja",
|
||||
"you": "Jij",
|
||||
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
|
||||
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Je hebt geen toestemming om deze map te archiveren.",
|
||||
"are_you_sure_you_want_to_archive": "Weet je zeker dat je deze map wilt archiveren? Workspaces hebben er dan geen toegang meer toe.",
|
||||
"assign_workspaces_description": "Bepaal welke workspaces toegang hebben tot deze feedbackregistratiemap.",
|
||||
"connectors_description": "Connectoren die feedbackrecords naar deze map sturen.",
|
||||
"create_feedback_directory": "Feedbackmap maken",
|
||||
"description": "Beheer feedbackregistratiemappen en hun workspace-toewijzingen.",
|
||||
"directory_archived_successfully": "Map succesvol gearchiveerd",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Map succesvol gedearchiveerd",
|
||||
"directory_updated_successfully": "Map succesvol bijgewerkt",
|
||||
"empty_state": "Geen feedbackregistratiemappen gevonden. Maak er een aan om te beginnen.",
|
||||
"enter_directory_name": "Voer mapnaam in",
|
||||
"error_directory_has_connectors": "Kan een map met gekoppelde connectoren niet archiveren. Verwijder eerst alle connectoren.",
|
||||
"error_directory_name_duplicate": "Er bestaat al een feedback-recordmap met deze naam.",
|
||||
"error_directory_name_required": "Mapnaam is verplicht.",
|
||||
"error_directory_workspaces_invalid_org": "Sommige opgegeven werkruimtes behoren niet tot deze organisatie.",
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
|
||||
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
"show_archived": "Gearchiveerde weergeven",
|
||||
"title": "Feedbackregistratiemappen",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Feedbackbron toevoegen",
|
||||
"add_source": "Bron toevoegen",
|
||||
"allowed_values": "Toegestane waarden: {values}",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
|
||||
"click_to_upload": "Klik om te uploaden",
|
||||
"collected_at": "Verzameld op",
|
||||
"configure_import": "Import configureren",
|
||||
"configure_mapping": "Koppeling configureren",
|
||||
"connection": "Verbinding",
|
||||
"connector_created_successfully": "Connector succesvol aangemaakt",
|
||||
"connector_deleted_successfully": "Connector succesvol verwijderd",
|
||||
"connector_duplicated_successfully": "Connector succesvol gedupliceerd",
|
||||
"connector_status_updated_successfully": "Connectorstatus succesvol bijgewerkt",
|
||||
"connector_updated_successfully": "Connector succesvol bijgewerkt",
|
||||
"connectors": "Connectoren",
|
||||
"create_mapping": "Koppeling aanmaken",
|
||||
"created_by": "Gemaakt door",
|
||||
"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_import_complete": "CSV-import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen",
|
||||
"csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.",
|
||||
"csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.",
|
||||
"csv_max_records": "Maximaal {max} records toegestaan.",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"deselect_all": "Alles deselecteren",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"edit_csv_mapping": "CSV-mapping bewerken",
|
||||
"edit_source_connection": "Bronverbinding bewerken",
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_record_directory": "Feedbackrecordmap",
|
||||
"feedback_record_fields": "Feedbackrecordvelden",
|
||||
"feedback_records": "Feedbackrecords",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"field_label": "Veldlabel",
|
||||
"field_type": "Veldtype",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackmap kan niet worden gewijzigd na aanmaak.",
|
||||
"go_to_feedback_record_directories": "Ga naar map-instellingen",
|
||||
"historical_import_complete": "Import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen (geen data)",
|
||||
"import_csv_data": "Feedback importeren",
|
||||
"import_feedback": "Feedback importeren",
|
||||
"import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
|
||||
"importing_data": "Gegevens importeren...",
|
||||
"importing_historical_data": "Historische gegevens importeren...",
|
||||
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
|
||||
"invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}",
|
||||
"load_sample_csv": "Voorbeeld-CSV laden",
|
||||
"n_supported_questions": "{count} ondersteunde vragen",
|
||||
"no_feedback_record_directory_available": "Geen feedbackrecordmap toegewezen aan deze workspace. Maak er eerst een aan of wijs er een toe.",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"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_drag_and_drop": "of sleep en zet neer",
|
||||
"question_selected": "<strong>{count}</strong> vraag geselecteerd. Elk antwoord op deze vraag zal een nieuw feedbackrecord aanmaken.",
|
||||
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
|
||||
"questions_selected": "<strong>{count}</strong> vragen geselecteerd. Elk antwoord op deze vragen zal een nieuw feedbackrecord aanmaken.",
|
||||
"records_will_go_to": "Records gaan naar",
|
||||
"refresh_feedback_records": "Feedbackrecords verversen",
|
||||
"refreshing_feedback_records": "Feedbackrecords vernieuwen...",
|
||||
"required": "Vereist",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
|
||||
"select_a_value": "Selecteer een waarde...",
|
||||
"select_all": "Selecteer alles",
|
||||
"select_feedback_record_directory": "Selecteer een map",
|
||||
"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_count_loaded": "Er worden {count} records weergegeven",
|
||||
"showing_rows": "3 van {count} rijen weergegeven",
|
||||
"source": "bron",
|
||||
"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": "Brontype",
|
||||
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
|
||||
"sources": "Bronnen",
|
||||
"status_active": "In uitvoering",
|
||||
"status_completed": "Voltooid",
|
||||
"status_draft": "Voorlopige versie",
|
||||
"status_error": "Fout",
|
||||
"status_paused": "Gepauzeerd",
|
||||
"survey_has_no_questions": "Deze enquête heeft geen vragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} antwoorden × {questionCount} vragen = {total} feedbackrecords",
|
||||
"total_feedback_records": "Totaal: {checked} van {total} feedbackrecords geselecteerd over {surveyCount} enquêtes",
|
||||
"unify_feedback": "Feedback verenigen",
|
||||
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren.",
|
||||
"upload_csv_file": "CSV-bestand uploaden",
|
||||
"user_identifier": "Gebruiker",
|
||||
"value": "Waarde"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Benut elk contactpunt om inzicht te krijgen in het gemak van klantinteractie.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} resposta} other {{count} respostas}}",
|
||||
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar pesquisa",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Ativar",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"failed_to_parse_csv": "Falha ao analisar CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Terminar",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
|
||||
"next": "Próximo",
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_code": "Sem código",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID da resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Tentar novamente",
|
||||
"role": "Rolê",
|
||||
"saas": "SaaS",
|
||||
"sales": "vendas",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 dia restante no seu período de teste",
|
||||
"try_again": "Tenta de novo",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Pesquisa desconhecida",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||
"update": "atualizar",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email Verificado",
|
||||
"video": "vídeo",
|
||||
"view": "Visualizar",
|
||||
"warning": "Aviso",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.",
|
||||
"webhook": "webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Você não tem permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tem certeza de que deseja arquivar este diretório? Os espaços de trabalho não terão mais acesso a ele.",
|
||||
"assign_workspaces_description": "Controle quais espaços de trabalho podem acessar este diretório de registros de feedback.",
|
||||
"connectors_description": "Conectores que enviam registros de feedback para este diretório.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gerencie diretórios de registros de feedback e suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Nenhum diretório de registros de feedback encontrado. Crie um para começar.",
|
||||
"enter_directory_name": "Digite o nome do diretório",
|
||||
"error_directory_has_connectors": "Não é possível arquivar um diretório que tem conectores vinculados a ele. Remova todos os conectores primeiro.",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registros de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_workspaces_invalid_org": "Alguns espaços de trabalho especificados não pertencem a esta organização.",
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Você não tem permissão para gerenciar diretórios de registros de feedback.",
|
||||
"no_connectors": "Nenhum conector vinculado a este diretório ainda.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registros de Feedback",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_settings_description": "Veja quais equipes podem acessar este workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"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",
|
||||
"collected_at": "Coletado em",
|
||||
"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_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
"connector_status_updated_successfully": "Status do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"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_import_complete": "Importação de CSV concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas",
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.",
|
||||
"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",
|
||||
"deselect_all": "Desmarcar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar conexão de origem",
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_directory": "Diretório de Registros de Feedback",
|
||||
"feedback_record_fields": "Campos do registro de feedback",
|
||||
"feedback_records": "Registros de feedback",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"field_label": "Rótulo do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para configurações de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"importing_data": "Importando dados...",
|
||||
"importing_historical_data": "Importando dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"no_feedback_record_directory_available": "Nenhum diretório de registros de feedback atribuído a este workspace. Crie ou atribua um primeiro.",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"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_drag_and_drop": "ou arraste e solte",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registro de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registro de feedback.",
|
||||
"records_will_go_to": "Os registros serão enviados para",
|
||||
"refresh_feedback_records": "Atualizar registros de feedback",
|
||||
"refreshing_feedback_records": "Atualizando registros de feedback...",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Salvar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecione um diretório",
|
||||
"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_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"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": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
|
||||
"sources": "Origens",
|
||||
"status_active": "Em andamento",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Pausado",
|
||||
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback selecionados em {surveyCount} pesquisas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Fazer upload de arquivo CSV",
|
||||
"user_identifier": "Usuário",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aproveite cada ponto de contato para entender a facilidade de interação do cliente.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} resposta} other {{count} respostas}}",
|
||||
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar inquérito",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Ativar",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"failed_to_parse_csv": "Falha ao analisar o CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Concluir",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
|
||||
"next": "Seguinte",
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_code": "Sem código",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID de resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Tentar novamente",
|
||||
"role": "Função",
|
||||
"saas": "SaaS",
|
||||
"sales": "Vendas",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 dia restante no teu período de teste",
|
||||
"try_again": "Tente novamente",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Inquérito desconhecido",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||
"update": "Atualizar",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email verificado",
|
||||
"video": "Vídeo",
|
||||
"view": "Ver",
|
||||
"warning": "Aviso",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "ex. Formbricks",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Não tens permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tens a certeza de que queres arquivar este diretório? Os espaços de trabalho deixarão de ter acesso ao mesmo.",
|
||||
"assign_workspaces_description": "Controla quais os espaços de trabalho que podem aceder a este diretório de registos de feedback.",
|
||||
"connectors_description": "Conectores que enviam registos de feedback para este diretório.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gere diretórios de registos de feedback e as suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Não foram encontrados diretórios de registos de feedback. Cria um para começar.",
|
||||
"enter_directory_name": "Insere o nome do diretório",
|
||||
"error_directory_has_connectors": "Não é possível arquivar um diretório que tem conectores associados. Remove todos os conectores primeiro.",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registos de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_workspaces_invalid_org": "Algumas áreas de trabalho especificadas não pertencem a esta organização.",
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Não tens permissão para gerir diretórios de registos de feedback.",
|
||||
"no_connectors": "Ainda não há conectores associados a este diretório.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registos de Feedback",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para carregar",
|
||||
"collected_at": "Recolhido em",
|
||||
"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_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
"connector_status_updated_successfully": "Estado do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"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_import_complete": "Importação de CSV concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados",
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.",
|
||||
"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",
|
||||
"deselect_all": "Desselecionar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar ligação de origem",
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_directory": "Diretório de Registos de Feedback",
|
||||
"feedback_record_fields": "Campos de registo de feedback",
|
||||
"feedback_records": "Registos de feedback",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"field_label": "Etiqueta do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para definições de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"importing_data": "A importar dados...",
|
||||
"importing_historical_data": "A importar dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"no_feedback_record_directory_available": "Não há nenhum diretório de registos de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"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_drag_and_drop": "ou arraste e largue",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registo de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registo de feedback.",
|
||||
"records_will_go_to": "Os registos irão para",
|
||||
"refresh_feedback_records": "Atualizar registos de feedback",
|
||||
"refreshing_feedback_records": "A atualizar registos de feedback...",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Guardar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecionar um diretório",
|
||||
"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_count_loaded": "A mostrar {count} registos",
|
||||
"showing_rows": "A mostrar 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"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": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
|
||||
"sources": "Fontes",
|
||||
"status_active": "Em progresso",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Em pausa",
|
||||
"survey_has_no_questions": "Este inquérito não tem perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registos de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registos de feedback selecionados em {surveyCount} inquéritos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Carrega um ficheiro CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Carregar ficheiro CSV",
|
||||
"user_identifier": "Utilizador",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aproveite todos os pontos de contato para entender a facilidade de interação do cliente.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {# întrebare} few {# întrebări} other {# de întrebări}}",
|
||||
"count_responses": "{count, plural, one {{count} răspuns} few {{count} răspunsuri} other {{count} de răspunsuri}}",
|
||||
"count_selections": "{count, plural, one {{count} selecție} few {{count} selecții} other {{count} de selecții}}",
|
||||
"create": "Creează",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_segment": "Creați segment",
|
||||
"create_survey": "Creează sondaj",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"enable": "Activează",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||
"failed_to_parse_csv": "Nu s-a putut procesa fișierul CSV",
|
||||
"filter": "Filtru",
|
||||
"finish": "Finalizează",
|
||||
"first_name": "Prenume",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Nou",
|
||||
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
|
||||
"next": "Următorul",
|
||||
"no": "Nu",
|
||||
"no_actions_found": "Nu au fost găsite acțiuni",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_code": "Fără Cod",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
"restart": "Repornește",
|
||||
"retry": "Reîncearcă",
|
||||
"role": "Rolul",
|
||||
"saas": "SaaS",
|
||||
"sales": "Vânzări",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
|
||||
"try_again": "Încearcă din nou",
|
||||
"type": "Tip",
|
||||
"unify": "Unifică",
|
||||
"unknown_survey": "Chestionar necunoscut",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
|
||||
"update": "Actualizare",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variante",
|
||||
"verified_email": "Email verificat",
|
||||
"video": "Video",
|
||||
"view": "Vizualizare",
|
||||
"warning": "Avertisment",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "ani",
|
||||
"yes": "Da",
|
||||
"you": "Tu",
|
||||
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Nu ai permisiunea să arhivezi acest director.",
|
||||
"are_you_sure_you_want_to_archive": "Ești sigur că vrei să arhivezi acest director? Spațiile de lucru nu vor mai avea acces la el.",
|
||||
"assign_workspaces_description": "Controlează care spații de lucru pot accesa acest director de înregistrări de feedback.",
|
||||
"connectors_description": "Conectori care trimit înregistrări de feedback către acest director.",
|
||||
"create_feedback_directory": "Creează director de feedback",
|
||||
"description": "Gestionează directoarele de înregistrări de feedback și atribuirile lor la spații de lucru.",
|
||||
"directory_archived_successfully": "Directorul a fost arhivat cu succes",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Directorul a fost dezarhivat cu succes",
|
||||
"directory_updated_successfully": "Directorul a fost actualizat cu succes",
|
||||
"empty_state": "Nu au fost găsite directoare de înregistrări de feedback. Creează unul pentru a începe.",
|
||||
"enter_directory_name": "Introdu numele directorului",
|
||||
"error_directory_has_connectors": "Nu poți arhiva un director care are conectori asociați. Elimină mai întâi toți conectorii.",
|
||||
"error_directory_name_duplicate": "Există deja un director de înregistrări feedback cu acest nume.",
|
||||
"error_directory_name_required": "Numele directorului este obligatoriu.",
|
||||
"error_directory_workspaces_invalid_org": "Unele spații de lucru specificate nu aparțin acestei organizații.",
|
||||
"nav_label": "Directoare de feedback",
|
||||
"no_access": "Nu ai permisiunea de a gestiona directoarele de înregistrări de feedback.",
|
||||
"no_connectors": "Niciun conector asociat acestui director încă.",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
"show_archived": "Afișează arhivate",
|
||||
"title": "Directoare de Înregistrări Feedback",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adaugă sursă de feedback",
|
||||
"add_source": "Adaugă sursă",
|
||||
"allowed_values": "Valori permise: {values}",
|
||||
"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",
|
||||
"collected_at": "Colectat la",
|
||||
"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_duplicated_successfully": "Conector duplicat cu succes",
|
||||
"connector_status_updated_successfully": "Statusul conectorului a fost actualizat cu succes",
|
||||
"connector_updated_successfully": "Conector actualizat cu succes",
|
||||
"connectors": "Conectori",
|
||||
"create_mapping": "Creează mapare",
|
||||
"created_by": "Creat de",
|
||||
"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_import_complete": "Import CSV finalizat: {successes} reușite, {failures} eșuate, {skipped} omise",
|
||||
"csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.",
|
||||
"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",
|
||||
"deselect_all": "Deselectează tot",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"edit_csv_mapping": "Editează maparea CSV",
|
||||
"edit_source_connection": "Editează conexiunea sursei",
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_record_directory": "Director de înregistrări feedback",
|
||||
"feedback_record_fields": "Câmpuri înregistrare feedback",
|
||||
"feedback_records": "Înregistrări de feedback",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"field_label": "Etichetă câmp",
|
||||
"field_type": "Tip câmp",
|
||||
"formbricks_surveys": "Chestionare Formbricks",
|
||||
"frd_cannot_be_changed": "Directorul de feedback nu poate fi modificat după creare.",
|
||||
"go_to_feedback_record_directories": "Mergi la setările directoarelor",
|
||||
"historical_import_complete": "Import finalizat: {successes} reușite, {failures} eșuate, {skipped} omise (fără date)",
|
||||
"import_csv_data": "Importă feedback",
|
||||
"import_feedback": "Importă feedback",
|
||||
"import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"importing_data": "Se importă datele...",
|
||||
"importing_historical_data": "Se importă datele istorice...",
|
||||
"invalid_enum_values": "Valori invalide în coloana mapată la {field}",
|
||||
"invalid_values_found": "Găsite: {values} (rânduri: {rows}) {extra}",
|
||||
"load_sample_csv": "Încarcă un CSV de exemplu",
|
||||
"n_supported_questions": "{count} întrebări acceptate",
|
||||
"no_feedback_record_directory_available": "Niciun director de înregistrări feedback atribuit acestui spațiu de lucru. Creează sau atribuie unul mai întâi.",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"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_drag_and_drop": "sau trage și lasă aici",
|
||||
"question_selected": "<strong>{count}</strong> întrebare selectată. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
|
||||
"questions_selected": "<strong>{count}</strong> întrebări selectate. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"records_will_go_to": "Înregistrările vor ajunge în",
|
||||
"refresh_feedback_records": "Reîmprospătează înregistrările de feedback",
|
||||
"refreshing_feedback_records": "Se actualizează înregistrările de feedback...",
|
||||
"required": "Obligatoriu",
|
||||
"save_changes": "Salvează modificările",
|
||||
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
|
||||
"select_a_value": "Selectează o valoare...",
|
||||
"select_all": "Selectează tot",
|
||||
"select_feedback_record_directory": "Selectează un director",
|
||||
"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_count_loaded": "Se afișează {count} înregistrări",
|
||||
"showing_rows": "Se afișează 3 din {count} rânduri",
|
||||
"source": "sursă",
|
||||
"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": "Tip sursă",
|
||||
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
|
||||
"sources": "Surse",
|
||||
"status_active": "În progres",
|
||||
"status_completed": "Finalizat",
|
||||
"status_draft": "Schiță",
|
||||
"status_error": "Eroare",
|
||||
"status_paused": "Pauzat",
|
||||
"survey_has_no_questions": "Acest sondaj nu are întrebări",
|
||||
"survey_import_line": "{surveyName}: {responseCount} răspunsuri × {questionCount} întrebări = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} din {total} Feedback Records selectate în {surveyCount} sondaje",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
|
||||
"updated_at": "Actualizat la",
|
||||
"upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback.",
|
||||
"upload_csv_file": "Încarcă fișier CSV",
|
||||
"user_identifier": "Utilizator",
|
||||
"value": "Valoare"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Valorificați fiecare punct de contact pentru a înțelege ușurința interacțiunilor cu clienții.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} вопрос} few {{count} вопроса} many {{count} вопросов} other {{count} вопросов}}",
|
||||
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответа}}",
|
||||
"count_selections": "{count, plural, one {{count} выбор} few {{count} выбора} many {{count} выборов} other {{count} выбора}}",
|
||||
"create": "Создать",
|
||||
"create_new_organization": "Создать новую организацию",
|
||||
"create_segment": "Создать сегмент",
|
||||
"create_survey": "Создать опрос",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"enable": "Включить",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
"enterprise_license": "Корпоративная лицензия",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"failed_to_parse_csv": "Не удалось обработать CSV",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Новый",
|
||||
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
|
||||
"next": "Далее",
|
||||
"no": "Нет",
|
||||
"no_actions_found": "Действия не найдены",
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_code": "Нет кода",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
"restart": "Перезапустить",
|
||||
"retry": "Повторить",
|
||||
"role": "Роль",
|
||||
"saas": "SaaS",
|
||||
"sales": "Продажи",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "Остался 1 день пробного периода",
|
||||
"try_again": "Попробуйте ещё раз",
|
||||
"type": "Тип",
|
||||
"unify": "Объединить",
|
||||
"unknown_survey": "Неизвестный опрос",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
|
||||
"update": "Обновить",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Переменные",
|
||||
"verified_email": "Подтверждённый email",
|
||||
"video": "Видео",
|
||||
"view": "Просмотр",
|
||||
"warning": "Предупреждение",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "например, Formbricks",
|
||||
"workspaces": "Рабочие пространства",
|
||||
"years": "годы",
|
||||
"yes": "Да",
|
||||
"you": "Вы",
|
||||
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "У тебя нет прав для архивирования этого каталога.",
|
||||
"are_you_sure_you_want_to_archive": "Ты уверен, что хочешь архивировать этот каталог? Рабочие пространства больше не будут иметь к нему доступа.",
|
||||
"assign_workspaces_description": "Управляй тем, какие рабочие пространства могут получить доступ к этому каталогу записей отзывов.",
|
||||
"connectors_description": "Коннекторы, которые отправляют записи обратной связи в этот каталог.",
|
||||
"create_feedback_directory": "Создать директорию для отзывов",
|
||||
"description": "Управляй каталогами записей отзывов и их назначением рабочим пространствам.",
|
||||
"directory_archived_successfully": "Каталог успешно архивирован",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Каталог успешно разархивирован",
|
||||
"directory_updated_successfully": "Каталог успешно обновлён",
|
||||
"empty_state": "Каталоги записей отзывов не найдены. Создай один, чтобы начать.",
|
||||
"enter_directory_name": "Введи название каталога",
|
||||
"error_directory_has_connectors": "Невозможно архивировать каталог, к которому привязаны коннекторы. Сначала удалите все коннекторы.",
|
||||
"error_directory_name_duplicate": "Директория с записями обратной связи с таким именем уже существует.",
|
||||
"error_directory_name_required": "Необходимо указать имя директории.",
|
||||
"error_directory_workspaces_invalid_org": "Некоторые указанные рабочие пространства не принадлежат этой организации.",
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
|
||||
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
"show_archived": "Показать архивные",
|
||||
"title": "Директории записей обратной связи",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Название команды",
|
||||
"team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Добавить источник отзывов",
|
||||
"add_source": "Добавить источник",
|
||||
"allowed_values": "Допустимые значения: {values}",
|
||||
"change_file": "Изменить файл",
|
||||
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
|
||||
"click_to_upload": "Кликните для загрузки",
|
||||
"collected_at": "Собрано",
|
||||
"configure_import": "Настроить импорт",
|
||||
"configure_mapping": "Настроить сопоставление",
|
||||
"connection": "Подключение",
|
||||
"connector_created_successfully": "Коннектор успешно создан",
|
||||
"connector_deleted_successfully": "Коннектор успешно удалён",
|
||||
"connector_duplicated_successfully": "Коннектор успешно дублирован",
|
||||
"connector_status_updated_successfully": "Статус коннектора успешно обновлён",
|
||||
"connector_updated_successfully": "Коннектор успешно обновлён",
|
||||
"connectors": "Коннекторы",
|
||||
"create_mapping": "Создать сопоставление",
|
||||
"created_by": "Создано пользователем",
|
||||
"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_import_complete": "Импорт CSV завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено",
|
||||
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
|
||||
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
|
||||
"csv_max_records": "Допустимо не более {max} записей.",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"edit_csv_mapping": "Редактировать сопоставление CSV",
|
||||
"edit_source_connection": "Редактировать подключение источника",
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_record_directory": "Каталог записей обратной связи",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
"feedback_records": "Записи отзывов",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"field_label": "Метка поля",
|
||||
"field_type": "Тип поля",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Каталог обратной связи нельзя изменить после создания.",
|
||||
"go_to_feedback_record_directories": "Перейти к настройкам каталогов",
|
||||
"historical_import_complete": "Импорт завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено (нет данных)",
|
||||
"import_csv_data": "Импортировать отзывы",
|
||||
"import_feedback": "Импортировать отзывы",
|
||||
"import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
|
||||
"importing_data": "Импорт данных...",
|
||||
"importing_historical_data": "Импорт исторических данных...",
|
||||
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
|
||||
"invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}",
|
||||
"load_sample_csv": "Загрузить пример CSV",
|
||||
"n_supported_questions": "Поддерживается {count} вопрос(ов)",
|
||||
"no_feedback_record_directory_available": "К этому рабочему пространству не назначен каталог записей обратной связи. Сначала создайте или назначьте каталог.",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"no_surveys_found": "В этой среде не найдено опросов",
|
||||
"optional": "Необязательно",
|
||||
"or_drag_and_drop": "или перетащите файл",
|
||||
"question_selected": "<strong>{count}</strong> выбранный вопрос. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"question_type_not_supported": "Этот тип вопроса не поддерживается",
|
||||
"questions_selected": "<strong>{count}</strong> выбранных вопроса. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"records_will_go_to": "Записи будут отправлены в",
|
||||
"refresh_feedback_records": "Обновить записи отзывов",
|
||||
"refreshing_feedback_records": "Обновляем записи отзывов...",
|
||||
"required": "Обязательно",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
|
||||
"select_a_value": "Выберите значение...",
|
||||
"select_all": "Выбрать все",
|
||||
"select_feedback_record_directory": "Выберите каталог",
|
||||
"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_count_loaded": "Показано записей: {count}",
|
||||
"showing_rows": "Показано 3 из {count} строк",
|
||||
"source": "источник",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_fields": "Поля источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type": "Тип источника",
|
||||
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
|
||||
"sources": "Источники",
|
||||
"status_active": "В процессе",
|
||||
"status_completed": "Завершён",
|
||||
"status_draft": "Черновик",
|
||||
"status_error": "Ошибка",
|
||||
"status_paused": "Приостановлен",
|
||||
"survey_has_no_questions": "В этом опросе нет вопросов",
|
||||
"survey_import_line": "{surveyName}: {responseCount} ответов × {questionCount} вопросов = {total} записей обратной связи",
|
||||
"total_feedback_records": "Всего: выбрано {checked} из {total} записей обратной связи в {surveyCount} опросах",
|
||||
"unify_feedback": "Обратная связь Unify",
|
||||
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
|
||||
"updated_at": "Обновлено",
|
||||
"upload_csv_data_description": "Загрузи CSV-файл, чтобы импортировать данные отзывов.",
|
||||
"upload_csv_file": "Загрузить CSV-файл",
|
||||
"user_identifier": "Пользователь",
|
||||
"value": "Значение"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Используйте каждый контакт с клиентом, чтобы понять, насколько легко с вами взаимодействовать.",
|
||||
|
||||
+124
-1
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} fråga} other {{count} frågor}}",
|
||||
"count_responses": "{count, plural, one {{count} svar} other {{count} svar}}",
|
||||
"count_selections": "{count, plural, one {{count} val} other {{count} val}}",
|
||||
"create": "Skapa",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
"create_segment": "Skapa segment",
|
||||
"create_survey": "Skapa enkät",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"enable": "Aktivera",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
"enterprise_license": "Företagslicens",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
|
||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||
"failed_to_parse_csv": "Det gick inte att tolka CSV-filen",
|
||||
"filter": "Filter",
|
||||
"finish": "Slutför",
|
||||
"first_name": "Förnamn",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "Ny",
|
||||
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
|
||||
"next": "Nästa",
|
||||
"no": "Nej",
|
||||
"no_actions_found": "Inga åtgärder hittades",
|
||||
"no_background_image_found": "Ingen bakgrundsbild hittades.",
|
||||
"no_code": "Ingen kod",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "Svar-ID",
|
||||
"responses": "Svar",
|
||||
"restart": "Starta om",
|
||||
"retry": "Försök igen",
|
||||
"role": "Roll",
|
||||
"saas": "SaaS",
|
||||
"sales": "Försäljning",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "1 dag kvar av din provperiod",
|
||||
"try_again": "Försök igen",
|
||||
"type": "Typ",
|
||||
"unify": "Förena",
|
||||
"unknown_survey": "Okänd enkät",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
|
||||
"update": "Uppdatera",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "Variabler",
|
||||
"verified_email": "Verifierad e-post",
|
||||
"video": "Video",
|
||||
"view": "Visa",
|
||||
"warning": "Varning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "t.ex. Formbricks",
|
||||
"workspaces": "Arbetsytor",
|
||||
"years": "år",
|
||||
"yes": "Ja",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "Du har inte behörighet att arkivera den här katalogen.",
|
||||
"are_you_sure_you_want_to_archive": "Är du säker på att du vill arkivera den här katalogen? Arbetsytor kommer inte längre ha tillgång till den.",
|
||||
"assign_workspaces_description": "Styr vilka arbetsytor som kan komma åt den här katalogen för feedbackposter.",
|
||||
"connectors_description": "Kopplingar som skickar feedbackposter till den här katalogen.",
|
||||
"create_feedback_directory": "Skapa feedbackkatalog",
|
||||
"description": "Hantera kataloger för feedbackposter och deras arbetsytstilldelningar.",
|
||||
"directory_archived_successfully": "Katalogen arkiverades",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "Katalogen återställdes från arkivet",
|
||||
"directory_updated_successfully": "Katalogen uppdaterades",
|
||||
"empty_state": "Inga kataloger för feedbackposter hittades. Skapa en för att komma igång.",
|
||||
"enter_directory_name": "Ange katalognamn",
|
||||
"error_directory_has_connectors": "Kan inte arkivera en katalog som har kopplingar kopplade till den. Ta bort alla kopplingar först.",
|
||||
"error_directory_name_duplicate": "En katalog för återkopplingsregister med detta namn finns redan.",
|
||||
"error_directory_name_required": "Katalognamn krävs.",
|
||||
"error_directory_workspaces_invalid_org": "Vissa angivna arbetsytor tillhör inte denna organisation.",
|
||||
"nav_label": "Feedbackkataloger",
|
||||
"no_access": "Du har inte behörighet att hantera kataloger för feedbackposter.",
|
||||
"no_connectors": "Inga kopplingar länkade till den här katalogen ännu.",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"title": "Feedbackkataloger",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_settings_description": "Se vilka team som har tillgång till denna arbetsyta."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Lägg till feedbackkälla",
|
||||
"add_source": "Lägg till källa",
|
||||
"allowed_values": "Tillåtna värden: {values}",
|
||||
"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",
|
||||
"collected_at": "Insamlad",
|
||||
"configure_import": "Konfigurera import",
|
||||
"configure_mapping": "Konfigurera mappning",
|
||||
"connection": "Anslutning",
|
||||
"connector_created_successfully": "Kopplingen skapades",
|
||||
"connector_deleted_successfully": "Kopplingen togs bort",
|
||||
"connector_duplicated_successfully": "Kopplingen har duplicerats",
|
||||
"connector_status_updated_successfully": "Kopplingens status har uppdaterats",
|
||||
"connector_updated_successfully": "Kopplingen uppdaterades",
|
||||
"connectors": "Kopplingar",
|
||||
"create_mapping": "Skapa mappning",
|
||||
"created_by": "Skapad av",
|
||||
"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_import_complete": "CSV-import klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över",
|
||||
"csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.",
|
||||
"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",
|
||||
"deselect_all": "Avmarkera alla",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
"drop_field_or": "Släpp fält eller",
|
||||
"edit_csv_mapping": "Redigera CSV-mappning",
|
||||
"edit_source_connection": "Redigera källans anslutning",
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_record_directory": "Katalog för feedbackposter",
|
||||
"feedback_record_fields": "Fält för feedbackpost",
|
||||
"feedback_records": "Feedbackposter",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"field_label": "Fältetikett",
|
||||
"field_type": "Fälttyp",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackkatalog kan inte ändras efter att den skapats.",
|
||||
"go_to_feedback_record_directories": "Gå till kataloginställningar",
|
||||
"historical_import_complete": "Importen klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över (ingen data)",
|
||||
"import_csv_data": "Importera feedback",
|
||||
"import_feedback": "Importera feedback",
|
||||
"import_rows": "Importera {count} rader",
|
||||
"importing_data": "Importerar data...",
|
||||
"importing_historical_data": "Importerar historisk data...",
|
||||
"invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}",
|
||||
"invalid_values_found": "Hittade: {values} (rader: {rows}) {extra}",
|
||||
"load_sample_csv": "Ladda exempel-CSV",
|
||||
"n_supported_questions": "{count} stödda frågor",
|
||||
"no_feedback_record_directory_available": "Ingen katalog för feedbackposter tilldelad till den här arbetsytan. Skapa eller tilldela en först.",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"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_drag_and_drop": "eller dra och släpp",
|
||||
"question_selected": "<strong>{count}</strong> fråga vald. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"question_type_not_supported": "Den här frågetypen stöds inte",
|
||||
"questions_selected": "<strong>{count}</strong> frågor valda. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"records_will_go_to": "Poster kommer att hamna i",
|
||||
"refresh_feedback_records": "Uppdatera feedbackposter",
|
||||
"refreshing_feedback_records": "Uppdaterar feedbackposter...",
|
||||
"required": "Obligatoriskt",
|
||||
"save_changes": "Spara ändringar",
|
||||
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
|
||||
"select_a_value": "Välj ett värde...",
|
||||
"select_all": "Välj alla",
|
||||
"select_feedback_record_directory": "Välj en katalog",
|
||||
"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_count_loaded": "Visar {count} poster",
|
||||
"showing_rows": "Visar 3 av {count} rader",
|
||||
"source": "källa",
|
||||
"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": "Källtyp",
|
||||
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
|
||||
"sources": "Källor",
|
||||
"status_active": "Pågående",
|
||||
"status_completed": "Slutförd",
|
||||
"status_draft": "Utkast",
|
||||
"status_error": "Fel",
|
||||
"status_paused": "Pausad",
|
||||
"survey_has_no_questions": "Den här enkäten har inga frågor",
|
||||
"survey_import_line": "{surveyName}: {responseCount} svar × {questionCount} frågor = {total} feedbackposter",
|
||||
"total_feedback_records": "Totalt: {checked} av {total} feedbackposter valda i {surveyCount} enkäter",
|
||||
"unify_feedback": "Samla feedback",
|
||||
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata.",
|
||||
"upload_csv_file": "Ladda upp CSV-fil",
|
||||
"user_identifier": "Användare",
|
||||
"value": "Värde"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Utnyttja varje kontaktpunkt för att förstå hur enkel kundinteraktionen är.",
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"count_questions": "共{count}个问题",
|
||||
"count_responses": "{count, plural, other {{count} 回复} }",
|
||||
"count_selections": "{count, plural, other {已选择{count}项}}",
|
||||
"create": "创建",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_segment": "创建 细分",
|
||||
"create_survey": "创建 调查",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"enable": "启用",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"failed_to_parse_csv": "CSV 解析失败",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "新建",
|
||||
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"no_actions_found": "未找到操作",
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_code": "无代码",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"retry": "重试",
|
||||
"role": "角色",
|
||||
"saas": "SaaS",
|
||||
"sales": "销售",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "试用期还剩 1 天",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unify": "统一",
|
||||
"unknown_survey": "未知调查",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
"view": "查看",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspaces": "工作区",
|
||||
"years": "年",
|
||||
"yes": "是",
|
||||
"you": "你 ",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "你无权归档此目录。",
|
||||
"are_you_sure_you_want_to_archive": "确定要归档此目录吗?工作区将无法再访问它。",
|
||||
"assign_workspaces_description": "控制哪些工作区可以访问此反馈记录目录。",
|
||||
"connectors_description": "将反馈记录发送到此目录的连接器。",
|
||||
"create_feedback_directory": "创建反馈目录",
|
||||
"description": "管理反馈记录目录及其工作区分配。",
|
||||
"directory_archived_successfully": "目录已成功归档",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "目录已成功取消归档",
|
||||
"directory_updated_successfully": "目录已成功更新",
|
||||
"empty_state": "未找到反馈记录目录。创建一个开始使用吧。",
|
||||
"enter_directory_name": "输入目录名称",
|
||||
"error_directory_has_connectors": "无法归档已链接连接器的目录。请先移除所有连接器。",
|
||||
"error_directory_name_duplicate": "已存在同名的反馈记录目录。",
|
||||
"error_directory_name_required": "目录名称为必填项。",
|
||||
"error_directory_workspaces_invalid_org": "某些指定的工作区不属于此组织。",
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈记录目录的权限。",
|
||||
"no_connectors": "此目录尚未链接任何连接器。",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
"show_archived": "显示已归档",
|
||||
"title": "反馈记录目录",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "团队名称",
|
||||
"team_settings_description": "查看哪些团队可以访问此工作区。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "添加反馈来源",
|
||||
"add_source": "添加来源",
|
||||
"allowed_values": "允许的值:{values}",
|
||||
"change_file": "更换文件",
|
||||
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
|
||||
"click_to_upload": "点击上传",
|
||||
"collected_at": "收集时间",
|
||||
"configure_import": "配置导入",
|
||||
"configure_mapping": "配置映射",
|
||||
"connection": "连接",
|
||||
"connector_created_successfully": "连接器创建成功",
|
||||
"connector_deleted_successfully": "连接器删除成功",
|
||||
"connector_duplicated_successfully": "连接器复制成功",
|
||||
"connector_status_updated_successfully": "连接器状态更新成功",
|
||||
"connector_updated_successfully": "连接器更新成功",
|
||||
"connectors": "连接器",
|
||||
"create_mapping": "创建映射",
|
||||
"created_by": "由 创建",
|
||||
"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_import_complete": "CSV 导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过",
|
||||
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
|
||||
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
|
||||
"csv_max_records": "最多允许 {max} 条记录。",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"deselect_all": "取消全选",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"edit_csv_mapping": "编辑 CSV 映射",
|
||||
"edit_source_connection": "编辑源连接",
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_record_directory": "反馈记录目录",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"field_label": "字段标签",
|
||||
"field_type": "字段类型",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "反馈目录创建后无法更改。",
|
||||
"go_to_feedback_record_directories": "前往目录设置",
|
||||
"historical_import_complete": "导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过(无数据)",
|
||||
"import_csv_data": "导入反馈",
|
||||
"import_feedback": "导入反馈",
|
||||
"import_rows": "导入{count}行数据",
|
||||
"importing_data": "正在导入数据…",
|
||||
"importing_historical_data": "正在导入历史数据…",
|
||||
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
|
||||
"invalid_values_found": "发现:{values}(行:{rows}){extra}",
|
||||
"load_sample_csv": "加载示例 CSV",
|
||||
"n_supported_questions": "{count} 个支持的问题",
|
||||
"no_feedback_record_directory_available": "此工作区未分配反馈记录目录。请先创建或分配一个。",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"no_surveys_found": "此环境下未找到调查",
|
||||
"optional": "可选",
|
||||
"or_drag_and_drop": "或拖放",
|
||||
"question_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"question_type_not_supported": "不支持此问题类型",
|
||||
"questions_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"records_will_go_to": "记录将发送至",
|
||||
"refresh_feedback_records": "刷新反馈记录",
|
||||
"refreshing_feedback_records": "正在刷新反馈记录…",
|
||||
"required": "必填",
|
||||
"save_changes": "保存更改",
|
||||
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
|
||||
"select_a_value": "选择一个值...",
|
||||
"select_all": "全选",
|
||||
"select_feedback_record_directory": "选择目录",
|
||||
"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_count_loaded": "显示 {count} 条记录",
|
||||
"showing_rows": "显示 {count} 行中的 3 行",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_name": "来源名称",
|
||||
"source_type": "来源类型",
|
||||
"source_type_cannot_be_changed": "来源类型无法更改",
|
||||
"sources": "来源",
|
||||
"status_active": "进行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "错误",
|
||||
"status_paused": "已暂停",
|
||||
"survey_has_no_questions": "该调查没有任何问题",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份答卷 × {questionCount} 个问题 = {total} 条反馈记录",
|
||||
"total_feedback_records": "总计:{surveyCount} 个调查中已选 {checked} / {total} 条反馈记录",
|
||||
"unify_feedback": "统一反馈",
|
||||
"update_mapping_description": "更新此来源的映射配置。",
|
||||
"updated_at": "更新于",
|
||||
"upload_csv_data_description": "上传 CSV 文件以导入反馈数据。",
|
||||
"upload_csv_file": "上传 CSV 文件",
|
||||
"user_identifier": "用户",
|
||||
"value": "值"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "客户努力评分",
|
||||
"ces_description": "利用 每个 接触点 来 了解 客户 互动 的 轻松 程度",
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
"code": "程式碼",
|
||||
"collapse_rows": "摺疊列",
|
||||
"completed": "已完成",
|
||||
"configuration": "組態",
|
||||
"configuration": "設定",
|
||||
"confirm": "確認",
|
||||
"connect": "連線",
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
@@ -186,6 +186,7 @@
|
||||
"count_questions": "{count, plural, other {{count} 個問題}}",
|
||||
"count_responses": "{count, plural, other {{count} 答覆}}",
|
||||
"count_selections": "{count, plural, other {{count} 個選擇}}",
|
||||
"create": "建立",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_segment": "建立區隔",
|
||||
"create_survey": "建立問卷",
|
||||
@@ -218,6 +219,7 @@
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"enable": "啟用",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
@@ -232,6 +234,7 @@
|
||||
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"failed_to_parse_csv": "CSV 解析失敗",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -300,6 +303,7 @@
|
||||
"new": "新增",
|
||||
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"no_actions_found": "找不到動作",
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_code": "無程式碼",
|
||||
@@ -376,6 +380,7 @@
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"retry": "重試",
|
||||
"role": "角色",
|
||||
"saas": "SaaS",
|
||||
"sales": "銷售",
|
||||
@@ -447,6 +452,7 @@
|
||||
"trial_one_day_remaining": "試用期剩餘 1 天",
|
||||
"try_again": "再試一次",
|
||||
"type": "類型",
|
||||
"unify": "統一",
|
||||
"unknown_survey": "未知問卷",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +470,7 @@
|
||||
"variables": "變數",
|
||||
"verified_email": "已驗證的電子郵件",
|
||||
"video": "影片",
|
||||
"view": "檢視",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +489,7 @@
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspaces": "工作區",
|
||||
"years": "年",
|
||||
"yes": "是",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
|
||||
@@ -2308,6 +2316,7 @@
|
||||
"archive_not_allowed": "您沒有權限封存此目錄。",
|
||||
"are_you_sure_you_want_to_archive": "確定要封存此目錄嗎?工作區將無法再存取它。",
|
||||
"assign_workspaces_description": "控制哪些工作區可以存取此意見回饋記錄目錄。",
|
||||
"connectors_description": "將意見回饋記錄傳送至此目錄的連接器。",
|
||||
"create_feedback_directory": "建立意見回饋目錄",
|
||||
"description": "管理意見回饋記錄目錄及其工作區配置。",
|
||||
"directory_archived_successfully": "目錄已成功封存",
|
||||
@@ -2319,12 +2328,13 @@
|
||||
"directory_unarchived_successfully": "目錄已成功取消封存",
|
||||
"directory_updated_successfully": "目錄已成功更新",
|
||||
"empty_state": "找不到任何意見回饋記錄目錄。建立一個開始使用吧。",
|
||||
"enter_directory_name": "輸入目錄名稱",
|
||||
"error_directory_has_connectors": "無法封存已連結連接器的目錄。請先移除所有連接器。",
|
||||
"error_directory_name_duplicate": "已存在同名的意見回饋記錄目錄。",
|
||||
"error_directory_name_required": "目錄名稱為必填項目。",
|
||||
"error_directory_workspaces_invalid_org": "部分指定的工作區不屬於此組織。",
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "您沒有權限管理意見回饋記錄目錄。",
|
||||
"no_connectors": "此目錄尚未連結任何連接器。",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
"show_archived": "顯示已封存",
|
||||
"title": "意見回饋記錄目錄",
|
||||
@@ -3368,6 +3378,119 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_settings_description": "查看哪些團隊可以存取此工作區。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "新增回饋來源",
|
||||
"add_source": "新增來源",
|
||||
"allowed_values": "允許的值:{values}",
|
||||
"change_file": "更換檔案",
|
||||
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
|
||||
"click_to_upload": "點擊以上傳",
|
||||
"collected_at": "收集時間",
|
||||
"configure_import": "設定匯入",
|
||||
"configure_mapping": "設定對應關係",
|
||||
"connection": "連線",
|
||||
"connector_created_successfully": "連接器建立成功",
|
||||
"connector_deleted_successfully": "連接器刪除成功",
|
||||
"connector_duplicated_successfully": "連接器複製成功",
|
||||
"connector_status_updated_successfully": "連接器狀態更新成功",
|
||||
"connector_updated_successfully": "連接器更新成功",
|
||||
"connectors": "連接器",
|
||||
"create_mapping": "建立對應關係",
|
||||
"created_by": "建立者",
|
||||
"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_import_complete": "CSV 匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過",
|
||||
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
|
||||
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
|
||||
"csv_max_records": "最多允許 {max} 筆紀錄。",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"deselect_all": "取消全選",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"edit_csv_mapping": "編輯 CSV 對應",
|
||||
"edit_source_connection": "編輯來源連線",
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_record_directory": "意見回饋記錄目錄",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"field_label": "欄位標籤",
|
||||
"field_type": "欄位類型",
|
||||
"formbricks_surveys": "Formbricks 問卷",
|
||||
"frd_cannot_be_changed": "意見回饋目錄在建立後無法變更。",
|
||||
"go_to_feedback_record_directories": "前往目錄設定",
|
||||
"historical_import_complete": "匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過(無資料)",
|
||||
"import_csv_data": "匯入 CSV 資料",
|
||||
"import_feedback": "匯入回饋",
|
||||
"import_rows": "匯入 {count} 筆資料",
|
||||
"importing_data": "正在匯入資料…",
|
||||
"importing_historical_data": "正在匯入歷史資料…",
|
||||
"invalid_enum_values": "對應到 {field} 欄位的值無效",
|
||||
"invalid_values_found": "發現:{values}(列:{rows}){extra}",
|
||||
"load_sample_csv": "載入範例 CSV",
|
||||
"n_supported_questions": "{count} 個支援的問題",
|
||||
"no_feedback_record_directory_available": "此工作區尚未指派意見回饋記錄目錄。請先建立或指派一個目錄。",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"no_surveys_found": "此環境中找不到問卷",
|
||||
"optional": "選填",
|
||||
"or_drag_and_drop": "或拖曳檔案",
|
||||
"question_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"question_type_not_supported": "不支援此題型",
|
||||
"questions_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"records_will_go_to": "記錄將傳送至",
|
||||
"refresh_feedback_records": "重新整理回饋紀錄",
|
||||
"refreshing_feedback_records": "正在更新回饋紀錄…",
|
||||
"required": "必填",
|
||||
"save_changes": "儲存變更",
|
||||
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
|
||||
"select_a_value": "請選擇一個值...",
|
||||
"select_all": "全選",
|
||||
"select_feedback_record_directory": "選擇目錄",
|
||||
"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_count_loaded": "顯示 {count} 筆記錄",
|
||||
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
|
||||
"source": "來源",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_name": "來源名稱",
|
||||
"source_type": "來源類型",
|
||||
"source_type_cannot_be_changed": "來源類型無法變更",
|
||||
"sources": "來源",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "錯誤",
|
||||
"status_paused": "已暫停",
|
||||
"survey_has_no_questions": "此問卷沒有任何題目",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份回應 × {questionCount} 題 = {total} 筆意見紀錄",
|
||||
"total_feedback_records": "總計:{surveyCount} 份問卷中已選擇 {checked} / {total} 筆意見紀錄",
|
||||
"unify_feedback": "整合回饋",
|
||||
"update_mapping_description": "更新此來源的對應設定。",
|
||||
"updated_at": "更新時間",
|
||||
"upload_csv_data_description": "上傳 CSV 檔案以匯入回饋資料。",
|
||||
"upload_csv_file": "上傳 CSV 檔案",
|
||||
"user_identifier": "使用者",
|
||||
"value": "值"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "利用每個接觸點來瞭解客戶互動的便利性。",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ZFeedbackRecordDirectoryUpdateInput } from "@/modules/ee/feedback-recor
|
||||
const ZCreateFeedbackRecordDirectoryAction = z.object({
|
||||
organizationId: ZId,
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
});
|
||||
|
||||
export const createFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
@@ -33,7 +34,11 @@ export const createFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createFeedbackRecordDirectory(parsedInput.organizationId, parsedInput.name);
|
||||
const result = await createFeedbackRecordDirectory(
|
||||
parsedInput.organizationId,
|
||||
parsedInput.name,
|
||||
parsedInput.workspaceIds
|
||||
);
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.feedbackRecordDirectoryId = result;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import {
|
||||
TFeedbackRecordDirectoryCreateInput,
|
||||
ZFeedbackRecordDirectoryCreateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface CreateFeedbackRecordDirectoryModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const CreateFeedbackRecordDirectoryModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
organizationId,
|
||||
}: CreateFeedbackRecordDirectoryModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TFeedbackRecordDirectoryCreateInput>({
|
||||
defaultValues: { name: "" },
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZFeedbackRecordDirectoryCreateInput),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = form;
|
||||
|
||||
const handleCreation: SubmitHandler<TFeedbackRecordDirectoryCreateInput> = async (data) => {
|
||||
const response = await createFeedbackRecordDirectoryAction({ name: data.name, organizationId });
|
||||
if (response?.data) {
|
||||
toast.success(t("workspace.settings.feedback_record_directories.directory_created_successfully"));
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
reset();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(handleCreation)} className="gap-y-4 pt-4">
|
||||
<DialogBody>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="pb-4">
|
||||
<FormLabel>
|
||||
{t("workspace.settings.feedback_record_directories.directory_name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("workspace.settings.feedback_record_directories.enter_directory_name")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!form.formState.isValid || isSubmitting} loading={isSubmitting} type="submit">
|
||||
{t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+94
-37
@@ -9,7 +9,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import {
|
||||
createFeedbackRecordDirectoryAction,
|
||||
updateFeedbackRecordDirectoryAction,
|
||||
} from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/archive-feedback-record-directory";
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
@@ -37,7 +40,8 @@ import { Muted } from "@/modules/ui/components/typography";
|
||||
interface FeedbackRecordDirectorySettingsModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
directory: TFeedbackRecordDirectoryDetails;
|
||||
directory?: TFeedbackRecordDirectoryDetails;
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
@@ -46,6 +50,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
directory,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectorySettingsModalProps) => {
|
||||
@@ -53,6 +58,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const router = useRouter();
|
||||
const isEdit = !!directory;
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() =>
|
||||
@@ -63,13 +69,13 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
);
|
||||
|
||||
const initialWorkspaceIds = useMemo(
|
||||
() => directory.workspaces.map((p) => p.workspaceId),
|
||||
[directory.workspaces]
|
||||
() => directory?.workspaces.map((p) => p.workspaceId) ?? [],
|
||||
[directory?.workspaces]
|
||||
);
|
||||
|
||||
const form = useForm<TFeedbackRecordDirectoryUpdateInput>({
|
||||
defaultValues: {
|
||||
name: directory.name,
|
||||
name: directory?.name ?? "",
|
||||
workspaceIds: initialWorkspaceIds,
|
||||
},
|
||||
mode: "onChange",
|
||||
@@ -81,24 +87,33 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
reset,
|
||||
} = form;
|
||||
|
||||
const closeSettingsModal = () => {
|
||||
const closeModal = () => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdate: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
workspaceIds: data.workspaceIds,
|
||||
},
|
||||
});
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = isEdit
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("workspace.settings.feedback_record_directories.directory_updated_successfully"));
|
||||
closeSettingsModal();
|
||||
toast.success(
|
||||
isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_updated_successfully")
|
||||
: t("workspace.settings.feedback_record_directories.directory_created_successfully")
|
||||
);
|
||||
closeModal();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
@@ -107,20 +122,24 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.directory_settings_title", {
|
||||
directoryName: directory.name,
|
||||
})}
|
||||
{isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_settings_title", {
|
||||
directoryName: directory.name,
|
||||
})
|
||||
: t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("workspace.settings.feedback_record_directories.directory_settings_description")}
|
||||
{isEdit
|
||||
? t("workspace.settings.feedback_record_directories.directory_settings_description")
|
||||
: t("workspace.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form className="contents space-y-4" onSubmit={handleSubmit(handleUpdate)}>
|
||||
<form className="contents space-y-4" onSubmit={handleSubmit(handleSubmitForm)}>
|
||||
<DialogBody className="flex-grow space-y-6 overflow-y-auto">
|
||||
<FormField
|
||||
control={control}
|
||||
@@ -143,11 +162,13 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("common.workspaces")}</FormLabel>
|
||||
@@ -156,7 +177,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</Muted>
|
||||
<MultiSelect
|
||||
options={workspaceOptions}
|
||||
value={form.watch("workspaceIds")}
|
||||
value={form.watch("workspaceIds") ?? []}
|
||||
onChange={(selected) => {
|
||||
setValue("workspaceIds", selected, { shouldDirty: true });
|
||||
}}
|
||||
@@ -167,20 +188,56 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
containerClassName="focus-within:ring-0 focus-within:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.unify.connectors")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.connectors_description")}
|
||||
</Muted>
|
||||
{directory.connectors.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-slate-200 p-3 text-center text-sm text-slate-400">
|
||||
{t("workspace.settings.feedback_record_directories.no_connectors")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{directory.connectors.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{c.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{c.type} · {c.workspaceName}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="text-xs font-medium text-slate-700 hover:text-slate-900 hover:underline"
|
||||
href={`/workspaces/${c.workspaceId}/unify/sources`}>
|
||||
{t("common.view")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<ArchiveFeedbackRecordDirectory
|
||||
directoryId={directory.id}
|
||||
onArchive={closeSettingsModal}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</div>
|
||||
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
|
||||
{isEdit && (
|
||||
<div className="w-full">
|
||||
<ArchiveFeedbackRecordDirectory
|
||||
directoryId={directory.id}
|
||||
onArchive={closeModal}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button size="default" type="button" variant="outline" onClick={closeModal}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="default" loading={isSubmitting} disabled={!isOwnerOrManager}>
|
||||
{t("common.save")}
|
||||
{isEdit ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
+10
-6
@@ -11,7 +11,6 @@ import {
|
||||
getFeedbackRecordDirectoryDetailsAction,
|
||||
updateFeedbackRecordDirectoryAction,
|
||||
} from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { CreateFeedbackRecordDirectoryModal } from "@/modules/ee/feedback-record-directory/components/create-feedback-record-directory-modal";
|
||||
import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal";
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
@@ -161,17 +160,22 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<CreateFeedbackRecordDirectoryModal
|
||||
open={openCreateModal}
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
{openCreateModal && (
|
||||
<FeedbackRecordDirectorySettingsModal
|
||||
open={openCreateModal}
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openSettingsModal && selectedDirectory && (
|
||||
<FeedbackRecordDirectorySettingsModal
|
||||
open={openSettingsModal}
|
||||
setOpen={setOpenSettingsModal}
|
||||
directory={selectedDirectory}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
|
||||
+156
-3
@@ -5,11 +5,18 @@ import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbr
|
||||
import {
|
||||
createFeedbackRecordDirectory,
|
||||
getFeedbackRecordDirectories,
|
||||
getFeedbackRecordDirectoriesByWorkspaceId,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
feedbackRecordDirectory: {
|
||||
@@ -18,9 +25,15 @@ vi.mock("@formbricks/database", () => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
connector: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -33,7 +46,7 @@ const mockDirectoryDbRow = {
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
_count: { workspaces: 2 },
|
||||
_count: { workspaces: 2, connectors: 1 },
|
||||
};
|
||||
|
||||
const mockDirectoryDetailsDbRow = {
|
||||
@@ -45,6 +58,7 @@ const mockDirectoryDetailsDbRow = {
|
||||
{ workspaceId: mockWorkspaceId1, workspace: { name: "Workspace A" } },
|
||||
{ workspaceId: mockWorkspaceId2, workspace: { name: "Workspace B" } },
|
||||
],
|
||||
connectors: [],
|
||||
};
|
||||
|
||||
describe("FeedbackRecordDirectory Service", () => {
|
||||
@@ -64,6 +78,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
workspaceCount: 2,
|
||||
connectorCount: 1,
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectory.findMany).toHaveBeenCalledWith({
|
||||
@@ -72,7 +87,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
_count: { select: { workspaces: true } },
|
||||
_count: { select: { workspaces: true, connectors: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -121,9 +136,38 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{ workspaceId: mockWorkspaceId1, workspaceName: "Workspace A" },
|
||||
{ workspaceId: mockWorkspaceId2, workspaceName: "Workspace B" },
|
||||
],
|
||||
connectors: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns directory details with connectors", async () => {
|
||||
const dbRowWithConnectors = {
|
||||
...mockDirectoryDetailsDbRow,
|
||||
connectors: [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspace: { name: "Workspace A" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(dbRowWithConnectors as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result?.connectors).toEqual([
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspaceName: "Workspace A",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when directory not found", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
@@ -158,6 +202,41 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a directory with workspace links", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(2);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecordDirectory(mockOrganizationId, "With Workspaces", [
|
||||
mockWorkspaceId1,
|
||||
mockWorkspaceId2,
|
||||
]);
|
||||
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
expect(prisma.workspace.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: [mockWorkspaceId1, mockWorkspaceId2] }, organizationId: mockOrganizationId },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "With Workspaces",
|
||||
organizationId: mockOrganizationId,
|
||||
workspaces: {
|
||||
create: [{ workspaceId: mockWorkspaceId1 }, { workspaceId: mockWorkspaceId2 }],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when workspaceIds belong to different org", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
createFeedbackRecordDirectory(mockOrganizationId, "Bad Workspaces", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_PROJECTS_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
@@ -203,7 +282,8 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("updates archive status", async () => {
|
||||
test("archives directory when no connectors linked", async () => {
|
||||
vi.mocked(prisma.connector.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
@@ -211,12 +291,37 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.connector.count).toHaveBeenCalledWith({
|
||||
where: { feedbackRecordDirectoryId: mockDirectoryId },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { isArchived: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when archiving directory with connectors", async () => {
|
||||
vi.mocked(prisma.connector.count).mockResolvedValueOnce(2);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { isArchived: true })
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_HAS_CONNECTORS"));
|
||||
});
|
||||
|
||||
test("unarchives directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { isArchived: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("updates workspace assignments with diff", async () => {
|
||||
// getFeedbackRecordDirectoryDetails call
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
@@ -293,6 +398,54 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectoriesByWorkspaceId", () => {
|
||||
test("returns directories assigned to workspace", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
|
||||
{ feedbackRecordDirectory: { id: mockDirectoryId, name: "Test Directory" } },
|
||||
] as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1);
|
||||
|
||||
expect(result).toEqual([{ id: mockDirectoryId, name: "Test Directory" }]);
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { isArchived: false },
|
||||
},
|
||||
select: {
|
||||
feedbackRecordDirectory: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories assigned", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getFeedbackRecordDirectoriesByWorkspaceId(mockWorkspaceId1)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromDirectoryId", () => {
|
||||
test("returns organization ID for a valid directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -38,6 +38,7 @@ export const getFeedbackRecordDirectories = reactCache(
|
||||
_count: {
|
||||
select: {
|
||||
workspaces: true,
|
||||
connectors: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,6 +52,7 @@ export const getFeedbackRecordDirectories = reactCache(
|
||||
name: dir.name,
|
||||
isArchived: dir.isArchived,
|
||||
workspaceCount: dir._count.workspaces,
|
||||
connectorCount: dir._count.connectors,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -70,6 +72,33 @@ export const getFeedbackRecordDirectories = reactCache(
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
/**
|
||||
* Lists feedback record directories assigned to a workspace.
|
||||
* Used by connector creation to pick an FRD.
|
||||
*/
|
||||
export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
|
||||
async (workspaceId: string): Promise<{ id: string; name: string }[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
try {
|
||||
const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
feedbackRecordDirectory: { isArchived: false },
|
||||
},
|
||||
select: {
|
||||
feedbackRecordDirectory: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => r.feedbackRecordDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
async (directoryId: string): Promise<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
@@ -93,6 +122,16 @@ export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
},
|
||||
},
|
||||
},
|
||||
connectors: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
workspaceId: true,
|
||||
workspace: { select: { name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,6 +148,13 @@ export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
workspaceId: dp.workspaceId,
|
||||
workspaceName: dp.workspace.name,
|
||||
})),
|
||||
connectors: directory.connectors.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
workspaceId: c.workspaceId,
|
||||
workspaceName: c.workspace.name,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -133,14 +179,28 @@ export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
*/
|
||||
export const createFeedbackRecordDirectory = async (
|
||||
organizationId: string,
|
||||
name: string
|
||||
name: string,
|
||||
workspaceIds?: string[]
|
||||
): Promise<string> => {
|
||||
validateInputs([organizationId, ZId], [name, z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED")]);
|
||||
try {
|
||||
// Verify workspaces belong to same org
|
||||
if (workspaceIds?.length) {
|
||||
const count = await prisma.workspace.count({
|
||||
where: { id: { in: workspaceIds }, organizationId },
|
||||
});
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_PROJECTS_INVALID_ORG");
|
||||
}
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackRecordDirectory.create({
|
||||
data: {
|
||||
name,
|
||||
organizationId,
|
||||
workspaces: workspaceIds?.length
|
||||
? { create: workspaceIds.map((workspaceId) => ({ workspaceId })) }
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -244,8 +304,16 @@ export const updateFeedbackRecordDirectory = async (
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
if (isArchived !== undefined) {
|
||||
payload.isArchived = isArchived;
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
payload.isArchived = true;
|
||||
} else if (isArchived === false) {
|
||||
payload.isArchived = false;
|
||||
}
|
||||
|
||||
if (workspaceIds !== undefined) {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ZFeedbackRecordDirectory = z.object({
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
workspaceCount: z.number(),
|
||||
connectorCount: z.number(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectory = z.infer<typeof ZFeedbackRecordDirectory>;
|
||||
@@ -21,12 +22,22 @@ export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
workspaceName: z.string(),
|
||||
})
|
||||
),
|
||||
connectors: z.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
workspaceId: ZId,
|
||||
workspaceName: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryCreateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryCreateInput = z.infer<typeof ZFeedbackRecordDirectoryCreateInput>;
|
||||
@@ -54,6 +65,8 @@ export const getTranslatedFeedbackRecordDirectoryError = (
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_name_duplicate");
|
||||
case "DIRECTORY_PROJECTS_INVALID_ORG":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_workspaces_invalid_org");
|
||||
case "DIRECTORY_HAS_CONNECTORS":
|
||||
return t("workspace.settings.feedback_record_directories.error_directory_has_connectors");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/hub", () => {
|
||||
// Must use `function` (not arrow) so it's valid as a `new` target.
|
||||
const MockFormbricksHub = vi.fn(function () {});
|
||||
return { default: MockFormbricksHub };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
HUB_API_KEY: "",
|
||||
HUB_API_URL: "https://hub.test",
|
||||
},
|
||||
}));
|
||||
|
||||
const { env } = await import("@/lib/env");
|
||||
|
||||
const mutableEnv = env as unknown as Record<string, string>;
|
||||
|
||||
const globalForHub = globalThis as unknown as {
|
||||
formbricksHubClient: FormbricksHub | undefined;
|
||||
};
|
||||
|
||||
describe("getHubClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalForHub.formbricksHubClient = undefined;
|
||||
});
|
||||
|
||||
test("returns null when HUB_API_KEY is not set", async () => {
|
||||
mutableEnv.HUB_API_KEY = "";
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
expect(FormbricksHub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates and caches a new client when HUB_API_KEY is set", async () => {
|
||||
mutableEnv.HUB_API_KEY = "test-key";
|
||||
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
vi.mocked(FormbricksHub).mockImplementation(function () {
|
||||
return mockInstance as any;
|
||||
});
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(FormbricksHub).toHaveBeenCalledWith({ apiKey: "test-key", baseURL: "https://hub.test" });
|
||||
expect(client).toBe(mockInstance);
|
||||
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
|
||||
});
|
||||
|
||||
test("returns cached client on subsequent calls", async () => {
|
||||
const cachedInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
globalForHub.formbricksHubClient = cachedInstance;
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(client).toBe(cachedInstance);
|
||||
expect(FormbricksHub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not cache null result so a later call with the key set can create the client", async () => {
|
||||
mutableEnv.HUB_API_KEY = "";
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const first = getHubClient();
|
||||
expect(first).toBeNull();
|
||||
expect(globalForHub.formbricksHubClient).toBeUndefined();
|
||||
|
||||
mutableEnv.HUB_API_KEY = "now-set";
|
||||
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
vi.mocked(FormbricksHub).mockImplementation(function () {
|
||||
return mockInstance as any;
|
||||
});
|
||||
|
||||
const second = getHubClient();
|
||||
expect(second).toBe(mockInstance);
|
||||
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import "server-only";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const globalForHub = globalThis as unknown as {
|
||||
formbricksHubClient: FormbricksHub | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a shared Formbricks Hub API client when HUB_API_KEY is set.
|
||||
* Uses a global singleton so the same instance is reused across the process
|
||||
* (and across Next.js HMR in development). When the key is not set, returns
|
||||
* null and does not cache that result so a later call with the key set
|
||||
* can create the client.
|
||||
*/
|
||||
export const getHubClient = (): FormbricksHub | null => {
|
||||
if (globalForHub.formbricksHubClient) {
|
||||
return globalForHub.formbricksHubClient;
|
||||
}
|
||||
const apiKey = env.HUB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const client = new FormbricksHub({ apiKey, baseURL: env.HUB_API_URL });
|
||||
globalForHub.formbricksHubClient = client;
|
||||
return client;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export { getHubClient } from "./hub-client";
|
||||
export {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
listFeedbackRecords,
|
||||
type CreateFeedbackRecordResult,
|
||||
type ListFeedbackRecordsResult,
|
||||
} from "./service";
|
||||
export type {
|
||||
FeedbackRecordCreateParams,
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createFeedbackRecord, createFeedbackRecordsBatch, listFeedbackRecords } from "./service";
|
||||
import type { FeedbackRecordCreateParams } from "./types";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("./hub-client", () => ({
|
||||
getHubClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
|
||||
const sampleInput: FeedbackRecordCreateParams = {
|
||||
field_id: "el-1",
|
||||
field_type: "rating",
|
||||
source_type: "formbricks",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
value_number: 5,
|
||||
collected_at: "2026-02-24T10:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("hub service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createFeedbackRecord", () => {
|
||||
test("returns error result when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns data when client.create succeeds", async () => {
|
||||
const created = { id: "hub-1", ...sampleInput };
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create: vi.fn().mockResolvedValue(created) },
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual(created);
|
||||
});
|
||||
|
||||
test("returns error result when client.create throws", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create: vi.fn().mockRejectedValue(new Error("Network error")) },
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ message: "Network error" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listFeedbackRecords", () => {
|
||||
test("returns error result when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await listFeedbackRecords({ tenant_id: "env-1" });
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns data when client.list succeeds", async () => {
|
||||
const listResponse = {
|
||||
data: [{ id: "rec-1", field_id: "el-1", field_type: "rating" }],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { list: vi.fn().mockResolvedValue(listResponse) },
|
||||
} as any);
|
||||
|
||||
const result = await listFeedbackRecords({ tenant_id: "env-1", limit: 50, offset: 0 });
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual(listResponse);
|
||||
});
|
||||
|
||||
test("returns error result when client.list throws", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { list: vi.fn().mockRejectedValue(new Error("Network error")) },
|
||||
} as any);
|
||||
|
||||
const result = await listFeedbackRecords({ tenant_id: "env-1" });
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ status: 0, message: "Network error" });
|
||||
});
|
||||
|
||||
test("returns data when called without params", async () => {
|
||||
const listResponse = { data: [], total: 0, limit: 50, offset: 0 };
|
||||
const listFn = vi.fn().mockResolvedValue(listResponse);
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { list: listFn },
|
||||
} as any);
|
||||
|
||||
const result = await listFeedbackRecords();
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual(listResponse);
|
||||
expect(listFn).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await createFeedbackRecordsBatch([sampleInput, { ...sampleInput, field_id: "el-2" }]);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
result.results.forEach((r) => {
|
||||
expect(r.data).toBeNull();
|
||||
expect(r.error?.message).toContain("HUB_API_KEY is not set");
|
||||
});
|
||||
});
|
||||
|
||||
test("returns results per input when client creates succeed", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: {
|
||||
create: vi
|
||||
.fn()
|
||||
.mockImplementation((input: FeedbackRecordCreateParams) =>
|
||||
Promise.resolve({ id: `hub-${input.field_id}`, ...input })
|
||||
),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
|
||||
const result = await createFeedbackRecordsBatch(inputs);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0].data).toMatchObject({ field_id: "el-1" });
|
||||
expect(result.results[0].error).toBeNull();
|
||||
expect(result.results[1].data).toMatchObject({ field_id: "el-2" });
|
||||
expect(result.results[1].error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns mixed results when some creates fail", async () => {
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "hub-1", ...sampleInput })
|
||||
.mockRejectedValueOnce(new Error("Rate limited"));
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create },
|
||||
} as any);
|
||||
|
||||
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
|
||||
const result = await createFeedbackRecordsBatch(inputs);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0].data).not.toBeNull();
|
||||
expect(result.results[0].error).toBeNull();
|
||||
expect(result.results[1].data).toBeNull();
|
||||
expect(result.results[1].error).toMatchObject({ message: "Rate limited" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import "server-only";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getHubClient } from "./hub-client";
|
||||
import type {
|
||||
FeedbackRecordCreateParams,
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
} from "./types";
|
||||
|
||||
export type CreateFeedbackRecordResult = {
|
||||
data: FeedbackRecordData | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
};
|
||||
|
||||
const NO_CONFIG_ERROR = {
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
} as const;
|
||||
|
||||
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a single feedback record in the Hub.
|
||||
* Returns a result shape with data or error; logs failures.
|
||||
*/
|
||||
export const createFeedbackRecord = async (
|
||||
input: FeedbackRecordCreateParams
|
||||
): Promise<CreateFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* List feedback records from the Hub with optional filters and pagination.
|
||||
*/
|
||||
export const listFeedbackRecords = async (
|
||||
params: FeedbackRecordListParams
|
||||
): Promise<ListFeedbackRecordsResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
try {
|
||||
const data = await client.feedbackRecords.list(params);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Hub: listFeedbackRecords failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create multiple feedback records in the Hub in parallel.
|
||||
* Returns an array of results (data or error) per input; logs failures.
|
||||
*/
|
||||
export const createFeedbackRecordsBatch = async (
|
||||
inputs: FeedbackRecordCreateParams[]
|
||||
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return {
|
||||
results: inputs.map(() => ({ data: null, error: { ...NO_CONFIG_ERROR } })),
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
inputs.map(async (input) => {
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null as CreateFeedbackRecordResult["error"] };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
})
|
||||
);
|
||||
return { results };
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type FormbricksHub from "@formbricks/hub";
|
||||
|
||||
export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParams;
|
||||
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
|
||||
export type FeedbackRecordListParams = FormbricksHub.FeedbackRecordListParams;
|
||||
export type FeedbackRecordListResponse = FormbricksHub.FeedbackRecordListResponse;
|
||||
@@ -38,6 +38,13 @@ vi.mock("@formbricks/database", () => ({
|
||||
workspaceTeam: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectory: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -94,10 +101,50 @@ describe("workspace lib", () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p2" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.workspaceTeam.createMany).mockResolvedValueOnce({} as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
const result = await createWorkspace("org1", { name: "Workspace 1", teamIds: ["t1"] });
|
||||
expect(result).toEqual(createdWorkspace);
|
||||
expect(prisma.workspace.create).toHaveBeenCalled();
|
||||
expect(prisma.workspaceTeam.createMany).toHaveBeenCalled();
|
||||
expect(prisma.feedbackRecordDirectory.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates workspace and links default FRD when first workspace", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p3" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(0);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
await createWorkspace("org1", { name: "Workspace No Teams" });
|
||||
|
||||
expect(prisma.feedbackRecordDirectory.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId_name: { organizationId: "org1", name: "Default Feedback Record Directory" },
|
||||
},
|
||||
create: { name: "Default Feedback Record Directory", organizationId: "org1" },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.count).toHaveBeenCalledWith({
|
||||
where: { feedbackRecordDirectoryId: "frd-1" },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
|
||||
data: { feedbackRecordDirectoryId: "frd-1", workspaceId: "p3" },
|
||||
});
|
||||
});
|
||||
|
||||
test("skips FRD link when default FRD already has links", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p4" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.upsert).mockResolvedValueOnce({ id: "frd-1" } as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.count).mockResolvedValueOnce(1);
|
||||
|
||||
await createWorkspace("org1", { name: "Second Workspace" });
|
||||
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ValidationError if name is missing", async () => {
|
||||
|
||||
@@ -89,6 +89,30 @@ export const createWorkspace = async (
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure default FRD exists + link to first workspace atomically
|
||||
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
|
||||
where: {
|
||||
organizationId_name: { organizationId, name: "Default Feedback Record Directory" },
|
||||
},
|
||||
create: { name: "Default Feedback Record Directory", organizationId },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Link only if this is the first workspace (no existing links for this FRD)
|
||||
const existingLinks = await prisma.feedbackRecordDirectoryWorkspace.count({
|
||||
where: { feedbackRecordDirectoryId: defaultFrd.id },
|
||||
});
|
||||
|
||||
if (existingLinks === 0) {
|
||||
await prisma.feedbackRecordDirectoryWorkspace.create({
|
||||
data: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@formbricks/cache": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
"@formbricks/hub": "0.4.3",
|
||||
"@formbricks/i18n-utils": "workspace:*",
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/logger": "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(/\/workspaces\/[^/]+\/look/);
|
||||
|
||||
@@ -173,7 +173,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(/\/workspaces\/[^/]+\/look/);
|
||||
|
||||
@@ -262,7 +262,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(/\/workspaces\/[^/]+\/look/);
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.waitForURL(/\/workspaces\/[^/]+\/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();
|
||||
|
||||
@@ -7,6 +7,14 @@ It also truncates the name to a maximum of 63 characters and removes trailing hy
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is never lost (63 char limit).
|
||||
*/}}
|
||||
{{- define "formbricks.hubname" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 59 | trimSuffix "-" }}
|
||||
{{- printf "%s-hub" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{/*
|
||||
Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is never lost (63 char limit).
|
||||
|
||||
@@ -17,7 +17,7 @@ formbricks:
|
||||
# REQUIRED: Base URL of the site (e.g., https://formbricks.example.com)
|
||||
# This is used for WEBAPP_URL and NEXTAUTH_URL
|
||||
webappUrl: ""
|
||||
|
||||
|
||||
# Optional: Public URL for surveys (defaults to webappUrl if not set)
|
||||
publicUrl: ""
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ x-environment: &environment
|
||||
|
||||
# The url of your Formbricks instance used in the admin panel
|
||||
# Set this to your public-facing URL, e.g., example http://localhost:3000 or https://example.com
|
||||
WEBAPP_URL:
|
||||
WEBAPP_URL:
|
||||
|
||||
# Required for next-auth. Should be the same as WEBAPP_URL
|
||||
NEXTAUTH_URL:
|
||||
NEXTAUTH_URL:
|
||||
|
||||
# PostgreSQL DB for Formbricks to connect to
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
|
||||
@@ -15,15 +15,15 @@ x-environment: &environment
|
||||
# NextJS Auth
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -hex 32` to generate one
|
||||
NEXTAUTH_SECRET:
|
||||
NEXTAUTH_SECRET:
|
||||
|
||||
# Encryption Key is used for 2FA & Single use URLs for Link Surveys
|
||||
# You can use: $(openssl rand -hex 32) to generate one
|
||||
ENCRYPTION_KEY:
|
||||
ENCRYPTION_KEY:
|
||||
|
||||
# API Secret for running cron jobs.
|
||||
# You can use: $(openssl rand -hex 32) to generate a secure one
|
||||
CRON_SECRET:
|
||||
CRON_SECRET:
|
||||
|
||||
# Redis URL for caching, rate limiting, and audit logging
|
||||
# To use external Redis/Valkey: remove the redis service below and update this URL
|
||||
@@ -262,7 +262,10 @@ services:
|
||||
image: ghcr.io/formbricks/hub:latest
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command: ["if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres \"$$DATABASE_URL\" up && /usr/local/bin/river migrate-up --database-url \"$$DATABASE_URL\"; else echo 'Migration tools (goose/river) not in image.'; exit 1; fi"]
|
||||
command:
|
||||
[
|
||||
'if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"; else echo ''Migration tools (goose/river) not in image.''; exit 1; fi',
|
||||
]
|
||||
environment:
|
||||
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
|
||||
depends_on:
|
||||
|
||||
@@ -8,100 +8,100 @@ icon: "code"
|
||||
|
||||
These variables are present inside your machine's docker-compose file. Restart the docker containers if you change any variables for them to take effect.
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
|
||||
| PUBLIC_URL | Base URL for the public domain where surveys and public-facing content are served. If not set, uses WEBAPP_URL. | optional | WEBAPP_URL |
|
||||
| NEXTAUTH_URL | Location of the auth server. This should normally be the same as WEBAPP_URL | required | http://localhost:3000 |
|
||||
| DATABASE_URL | Database URL with credentials. | required | |
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| ENCRYPTION_KEY | Secret used by Formbricks for data encryption and audit log hashing. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| CRON_SECRET | API Secret for running cron jobs. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| LOG_LEVEL | Minimum log level (debug, info, warn, error, fatal) | optional | info |
|
||||
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
|
||||
| S3_ENDPOINT_URL | Endpoint for S3. | optional | (resolved by the AWS SDK) |
|
||||
| SAML_DATABASE_URL | Database URL for SAML. | optional | postgres://postgres:@localhost:5432/formbricks-saml |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
| IMPRINT_URL | URL for imprint. | optional | |
|
||||
| IMPRINT_ADDRESS | Address for imprint. | optional | |
|
||||
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_AUTHENTICATED | If set to 0, the server will not require SMTP_USER and SMTP_PASSWORD(default is 1) | optional | |
|
||||
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to 1 else to 0. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_REJECT_UNAUTHORIZED_TLS | If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs. | optional | 1 |
|
||||
| TURNSTILE_SITE_KEY | Site key for Turnstile. | optional | |
|
||||
| TURNSTILE_SECRET_KEY | Secret key for Turnstile. | optional | |
|
||||
| RECAPTCHA_SITE_KEY | Site key for survey responses recaptcha bot protection | optional | |
|
||||
| RECAPTCHA_SECRET_KEY | Secret key for recaptcha bot protection. | optional | |
|
||||
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
|
||||
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
||||
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
|
||||
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
|
||||
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_LOCATION | Google Cloud location for Vertex AI requests. | optional (required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_CREDENTIALS_JSON | Service account credentials JSON for Vertex AI. | optional (one of this or `AI_GCP_APPLICATION_CREDENTIALS` required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_APPLICATION_CREDENTIALS | Path to Google Application Default Credentials used for Vertex AI. | optional (one of this or `AI_GCP_CREDENTIALS_JSON` required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_AWS_REGION | AWS region for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_ACCESS_KEY_ID | AWS access key ID for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_SECRET_ACCESS_KEY | AWS secret access key for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_SESSION_TOKEN | AWS session token for Amazon Bedrock temporary credentials. | optional | |
|
||||
| AI_AZURE_BASE_URL | Azure OpenAI / Foundry base URL. When set, this is preferred over `AI_AZURE_RESOURCE_NAME`. | optional | |
|
||||
| AI_AZURE_RESOURCE_NAME | Azure resource name used to assemble the Azure OpenAI URL. | optional | |
|
||||
| AI_AZURE_API_KEY | API key for Azure OpenAI / Foundry. | optional (required if `AI_PROVIDER=azure`) | |
|
||||
| AI_AZURE_API_VERSION | Azure API version for OpenAI-compatible calls. | optional | v1 |
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
|
||||
| OTEL_EXPORTER_OTLP_ENDPOINT | Base OTLP HTTP endpoint for traces and metrics export (e.g. http://collector:4318). | optional | |
|
||||
| OTEL_EXPORTER_OTLP_PROTOCOL | OTLP protocol to use for export. | optional | http/protobuf |
|
||||
| OTEL_SERVICE_NAME | Service name reported in OpenTelemetry resource attributes. | optional | formbricks |
|
||||
| OTEL_RESOURCE_ATTRIBUTES | Comma-separated resource attributes in OTel format (`key=value,key2=value2`). | optional | |
|
||||
| OTEL_TRACES_SAMPLER | Trace sampler strategy (`always_on`, `always_off`, `traceidratio`, `parentbased_traceidratio`). | optional | always_on |
|
||||
| OTEL_TRACES_SAMPLER_ARG | Sampling argument used by ratio-based samplers (`0` to `1`). | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
|
||||
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | |
|
||||
| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | |
|
||||
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | |
|
||||
| SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) |
|
||||
| USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager |
|
||||
| REDIS_URL | Redis URL for caching, rate limiting, and audit logging. Application will not start without this. | required | redis://localhost:6379 |
|
||||
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
|
||||
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
|
||||
| Variable | Description | Required | Default |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
|
||||
| PUBLIC_URL | Base URL for the public domain where surveys and public-facing content are served. If not set, uses WEBAPP_URL. | optional | WEBAPP_URL |
|
||||
| NEXTAUTH_URL | Location of the auth server. This should normally be the same as WEBAPP_URL | required | http://localhost:3000 |
|
||||
| DATABASE_URL | Database URL with credentials. | required | |
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| ENCRYPTION_KEY | Secret used by Formbricks for data encryption and audit log hashing. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| CRON_SECRET | API Secret for running cron jobs. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) |
|
||||
| LOG_LEVEL | Minimum log level (debug, info, warn, error, fatal) | optional | info |
|
||||
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
|
||||
| S3_ENDPOINT_URL | Endpoint for S3. | optional | (resolved by the AWS SDK) |
|
||||
| SAML_DATABASE_URL | Database URL for SAML. | optional | postgres://postgres:@localhost:5432/formbricks-saml |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
| IMPRINT_URL | URL for imprint. | optional | |
|
||||
| IMPRINT_ADDRESS | Address for imprint. | optional | |
|
||||
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_AUTHENTICATED | If set to 0, the server will not require SMTP_USER and SMTP_PASSWORD(default is 1) | optional | |
|
||||
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to 1 else to 0. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_REJECT_UNAUTHORIZED_TLS | If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs. | optional | 1 |
|
||||
| TURNSTILE_SITE_KEY | Site key for Turnstile. | optional | |
|
||||
| TURNSTILE_SECRET_KEY | Secret key for Turnstile. | optional | |
|
||||
| RECAPTCHA_SITE_KEY | Site key for survey responses recaptcha bot protection | optional | |
|
||||
| RECAPTCHA_SECRET_KEY | Secret key for recaptcha bot protection. | optional | |
|
||||
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
|
||||
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
||||
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
|
||||
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
|
||||
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_LOCATION | Google Cloud location for Vertex AI requests. | optional (required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_CREDENTIALS_JSON | Service account credentials JSON for Vertex AI. | optional (one of this or `AI_GCP_APPLICATION_CREDENTIALS` required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_GCP_APPLICATION_CREDENTIALS | Path to Google Application Default Credentials used for Vertex AI. | optional (one of this or `AI_GCP_CREDENTIALS_JSON` required if `AI_PROVIDER=gcp`) | |
|
||||
| AI_AWS_REGION | AWS region for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_ACCESS_KEY_ID | AWS access key ID for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_SECRET_ACCESS_KEY | AWS secret access key for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
|
||||
| AI_AWS_SESSION_TOKEN | AWS session token for Amazon Bedrock temporary credentials. | optional | |
|
||||
| AI_AZURE_BASE_URL | Azure OpenAI / Foundry base URL. When set, this is preferred over `AI_AZURE_RESOURCE_NAME`. | optional | |
|
||||
| AI_AZURE_RESOURCE_NAME | Azure resource name used to assemble the Azure OpenAI URL. | optional | |
|
||||
| AI_AZURE_API_KEY | API key for Azure OpenAI / Foundry. | optional (required if `AI_PROVIDER=azure`) | |
|
||||
| AI_AZURE_API_VERSION | Azure API version for OpenAI-compatible calls. | optional | v1 |
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
|
||||
| OTEL_EXPORTER_OTLP_ENDPOINT | Base OTLP HTTP endpoint for traces and metrics export (e.g. http://collector:4318). | optional | |
|
||||
| OTEL_EXPORTER_OTLP_PROTOCOL | OTLP protocol to use for export. | optional | http/protobuf |
|
||||
| OTEL_SERVICE_NAME | Service name reported in OpenTelemetry resource attributes. | optional | formbricks |
|
||||
| OTEL_RESOURCE_ATTRIBUTES | Comma-separated resource attributes in OTel format (`key=value,key2=value2`). | optional | |
|
||||
| OTEL_TRACES_SAMPLER | Trace sampler strategy (`always_on`, `always_off`, `traceidratio`, `parentbased_traceidratio`). | optional | always_on |
|
||||
| OTEL_TRACES_SAMPLER_ARG | Sampling argument used by ratio-based samplers (`0` to `1`). | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
|
||||
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | |
|
||||
| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | |
|
||||
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | |
|
||||
| SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) |
|
||||
| USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager |
|
||||
| REDIS_URL | Redis URL for caching, rate limiting, and audit logging. Application will not start without this. | required | redis://localhost:6379 |
|
||||
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
|
||||
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
|
||||
|
||||
#### Formbricks Hub
|
||||
|
||||
When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) (for example via Docker Compose or Helm), the following variables apply:
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------ | -------------------------- | ----------------------------------------------------- |
|
||||
| HUB_API_KEY | API key used by the Formbricks Hub API (port 8080). | required | (e.g. `openssl rand -hex 32`) |
|
||||
| HUB_API_URL | Base URL the Formbricks app uses to call Hub. Use `http://localhost:8080` locally. | required | `http://localhost:8080` in local dev |
|
||||
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------- | ---------------------------------------------------------------------------------- | -------- | --------------------------------------------------- |
|
||||
| HUB_API_KEY | API key used by the Formbricks Hub API (port 8080). | required | (e.g. `openssl rand -hex 32`) |
|
||||
| HUB_API_URL | Base URL the Formbricks app uses to call Hub. Use `http://localhost:8080` locally. | required | `http://localhost:8080` in local dev |
|
||||
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectoryProject" (
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"feedbackRecordDirectoryId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectoryProject_pkey" PRIMARY KEY ("feedbackRecordDirectoryId","projectId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackRecordDirectory_organizationId_name_key" ON "FeedbackRecordDirectory"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackRecordDirectoryProject_projectId_idx" ON "FeedbackRecordDirectoryProject"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectory" ADD CONSTRAINT "FeedbackRecordDirectory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryProject" ADD CONSTRAINT "FeedbackRecordDirectoryProject_feedbackRecordDirectoryId_fkey" FOREIGN KEY ("feedbackRecordDirectoryId") REFERENCES "FeedbackRecordDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryProject" ADD CONSTRAINT "FeedbackRecordDirectoryProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -4,7 +4,6 @@ ALTER TYPE "ProjectTeamPermission" RENAME TO "WorkspaceTeamPermission";
|
||||
-- Rename tables
|
||||
ALTER TABLE "Project" RENAME TO "Workspace";
|
||||
ALTER TABLE "ProjectTeam" RENAME TO "WorkspaceTeam";
|
||||
ALTER TABLE "FeedbackRecordDirectoryProject" RENAME TO "FeedbackRecordDirectoryWorkspace";
|
||||
|
||||
-- Rename "Workspace" (was "Project") constraints and indexes
|
||||
ALTER TABLE "Workspace" RENAME CONSTRAINT "Project_pkey" TO "Workspace_pkey";
|
||||
@@ -33,14 +32,6 @@ ALTER TABLE "WorkspaceTeam" RENAME CONSTRAINT "ProjectTeam_projectId_fkey" TO "W
|
||||
ALTER TABLE "WorkspaceTeam" RENAME CONSTRAINT "ProjectTeam_teamId_fkey" TO "WorkspaceTeam_teamId_fkey";
|
||||
ALTER INDEX "ProjectTeam_teamId_idx" RENAME TO "WorkspaceTeam_teamId_idx";
|
||||
|
||||
-- Rename "FeedbackRecordDirectoryWorkspace" (was "FeedbackRecordDirectoryProject") columns and constraints
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" RENAME COLUMN "projectId" TO "workspaceId";
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" DROP CONSTRAINT "FeedbackRecordDirectoryProject_pkey";
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" ADD CONSTRAINT "FeedbackRecordDirectoryWorkspace_pkey" PRIMARY KEY ("feedbackRecordDirectoryId", "workspaceId");
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" RENAME CONSTRAINT "FeedbackRecordDirectoryProject_feedbackRecordDirectoryId_fkey" TO "FeedbackRecordDirectoryWorkspace_feedbackRecordDirectoryId_fkey";
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" RENAME CONSTRAINT "FeedbackRecordDirectoryProject_projectId_fkey" TO "FeedbackRecordDirectoryWorkspace_workspaceId_fkey";
|
||||
ALTER INDEX "FeedbackRecordDirectoryProject_projectId_idx" RENAME TO "FeedbackRecordDirectoryWorkspace_workspaceId_idx";
|
||||
|
||||
-- Rename JSON key "projects" to "workspaces" inside OrganizationBilling.limits
|
||||
UPDATE "OrganizationBilling"
|
||||
SET limits = jsonb_set(
|
||||
|
||||
@@ -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',
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"created_by" 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,
|
||||
"workspaceId" 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,
|
||||
"workspaceId" 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_workspaceId_key" ON "public"."Connector"("id", "workspaceId");
|
||||
CREATE UNIQUE INDEX "Connector_workspaceId_name_key" ON "public"."Connector"("workspaceId", "name");
|
||||
CREATE INDEX "Connector_type_idx" ON "public"."Connector"("type");
|
||||
|
||||
-- ConnectorFormbricksMapping indexes
|
||||
CREATE UNIQUE INDEX "ConnectorFormbricksMapping_workspaceId_connectorId_survey_key" ON "public"."ConnectorFormbricksMapping"("workspaceId", "connectorId", "surveyId", "elementId");
|
||||
CREATE INDEX "ConnectorFormbricksMapping_workspaceId_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("workspaceId", "surveyId");
|
||||
CREATE INDEX "ConnectorFormbricksMapping_surveyId_idx" ON "public"."ConnectorFormbricksMapping"("surveyId");
|
||||
|
||||
-- ConnectorFieldMapping indexes
|
||||
CREATE UNIQUE INDEX "ConnectorFieldMapping_workspaceId_connectorId_source_fiel_key" ON "public"."ConnectorFieldMapping"("workspaceId", "connectorId", "source_field_id", "target_field_id");
|
||||
|
||||
-- Survey composite unique (for composite FK from ConnectorFormbricksMapping)
|
||||
CREATE UNIQUE INDEX "Survey_id_workspaceId_key" ON "public"."Survey"("id", "workspaceId");
|
||||
|
||||
-- Foreign keys: Connector -> Workspace, Connector -> User (creator)
|
||||
ALTER TABLE "public"."Connector" ADD CONSTRAINT "Connector_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "public"."Connector" ADD CONSTRAINT "Connector_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Foreign keys: ConnectorFormbricksMapping -> Connector (composite), Survey (composite)
|
||||
ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_connectorId_workspaceId_fkey" FOREIGN KEY ("connectorId", "workspaceId") REFERENCES "public"."Connector"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "public"."ConnectorFormbricksMapping" ADD CONSTRAINT "ConnectorFormbricksMapping_surveyId_workspaceId_fkey" FOREIGN KEY ("surveyId", "workspaceId") REFERENCES "public"."Survey"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Foreign keys: ConnectorFieldMapping -> Connector (composite)
|
||||
ALTER TABLE "public"."ConnectorFieldMapping" ADD CONSTRAINT "ConnectorFieldMapping_connectorId_workspaceId_fkey" FOREIGN KEY ("connectorId", "workspaceId") REFERENCES "public"."Connector"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectoryWorkspace" (
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"feedbackRecordDirectoryId" TEXT NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectoryWorkspace_pkey" PRIMARY KEY ("feedbackRecordDirectoryId","workspaceId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackRecordDirectory_organizationId_name_key" ON "FeedbackRecordDirectory"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackRecordDirectoryWorkspace_workspaceId_idx" ON "FeedbackRecordDirectoryWorkspace"("workspaceId");
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "ApiKeyWorkspace" RENAME CONSTRAINT "ApiKeyEnvironment_apiKeyId_fkey" TO "ApiKeyWorkspace_apiKeyId_fkey";
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "ApiKeyWorkspace" RENAME CONSTRAINT "ApiKeyEnvironment_workspaceId_fkey" TO "ApiKeyWorkspace_workspaceId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectory" ADD CONSTRAINT "FeedbackRecordDirectory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" ADD CONSTRAINT "FeedbackRecordDirectoryWorkspace_feedbackRecordDirectoryId_fkey" FOREIGN KEY ("feedbackRecordDirectoryId") REFERENCES "FeedbackRecordDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryWorkspace" ADD CONSTRAINT "FeedbackRecordDirectoryWorkspace_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Connector → FeedbackRecordDirectory FK
|
||||
ALTER TABLE "Connector" ADD COLUMN "feedbackRecordDirectoryId" TEXT NOT NULL;
|
||||
ALTER TABLE "Connector" ADD CONSTRAINT "Connector_feedbackRecordDirectoryId_fkey" FOREIGN KEY ("feedbackRecordDirectoryId") REFERENCES "FeedbackRecordDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
CREATE INDEX "Connector_feedbackRecordDirectoryId_idx" ON "Connector"("feedbackRecordDirectoryId");
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "ConnectorFieldMapping_workspaceId_connectorId_source_fiel_key" RENAME TO "ConnectorFieldMapping_workspaceId_connectorId_source_field__key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "ConnectorFormbricksMapping_workspaceId_connectorId_survey_key" RENAME TO "ConnectorFormbricksMapping_workspaceId_connectorId_surveyId_key";
|
||||
@@ -393,26 +393,28 @@ model Survey {
|
||||
/// [SurveySingleUse]
|
||||
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
|
||||
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isAutoProgressingEnabled Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isAutoProgressingEnabled 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, workspaceId])
|
||||
@@index([segmentId])
|
||||
@@index([workspaceId, updatedAt])
|
||||
}
|
||||
@@ -624,6 +626,7 @@ model Workspace {
|
||||
segments Segment[]
|
||||
integrations Integration[]
|
||||
apiKeyWorkspaces ApiKeyWorkspace[]
|
||||
connectors Connector[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
@@ -908,6 +911,7 @@ model User {
|
||||
/// [Locale]
|
||||
locale String @default("en-US")
|
||||
surveys Survey[]
|
||||
connectors Connector[]
|
||||
teamUsers TeamUser[]
|
||||
lastLoginAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
@@ -1047,6 +1051,110 @@ model WorkspaceTeam {
|
||||
@@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)
|
||||
/// @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)
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
feedbackRecordDirectoryId String
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id], onDelete: Cascade)
|
||||
formbricksMappings ConnectorFormbricksMapping[]
|
||||
fieldMappings ConnectorFieldMapping[]
|
||||
lastSyncAt DateTime? @map(name: "last_sync_at")
|
||||
createdBy String? @map(name: "created_by")
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([id, workspaceId])
|
||||
@@unique([workspaceId, name])
|
||||
@@index([type])
|
||||
@@index([feedbackRecordDirectoryId])
|
||||
}
|
||||
|
||||
/// 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
|
||||
workspaceId String
|
||||
connector Connector @relation(fields: [connectorId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
|
||||
surveyId String
|
||||
survey Survey @relation(fields: [surveyId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
|
||||
elementId String
|
||||
hubFieldType HubFieldType
|
||||
customFieldLabel String? @map(name: "custom_field_label")
|
||||
|
||||
@@unique([workspaceId, connectorId, surveyId, elementId])
|
||||
@@index([workspaceId, 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
|
||||
workspaceId String
|
||||
connector Connector @relation(fields: [connectorId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
|
||||
sourceFieldId String @map(name: "source_field_id")
|
||||
targetFieldId String @map(name: "target_field_id")
|
||||
staticValue String? @map(name: "static_value")
|
||||
|
||||
@@unique([workspaceId, connectorId, sourceFieldId, targetFieldId])
|
||||
}
|
||||
|
||||
/// Represents a feedback record directory (Hub tenant) owned by an organization.
|
||||
/// Directories group feedback data and are assigned to workspaces for access control.
|
||||
///
|
||||
@@ -1064,6 +1172,7 @@ model FeedbackRecordDirectory {
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
workspaces FeedbackRecordDirectoryWorkspace[]
|
||||
connectors Connector[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
|
||||
@@ -373,6 +373,18 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
|
||||
where: {
|
||||
organizationId_name: { organizationId: organization.id, name: "Default Feedback Record Directory" },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
name: "Default Feedback Record Directory",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Users
|
||||
const passwordHash = await bcrypt.hash(SEED_CREDENTIALS.ADMIN.password, 10);
|
||||
|
||||
@@ -444,6 +456,21 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Link default FRD to workspace
|
||||
await prisma.feedbackRecordDirectoryWorkspace.upsert({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
feedbackRecordDirectoryId: defaultFrd.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Contact attribute keys for the workspace
|
||||
const defaultAttributeKeys = [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" as const },
|
||||
|
||||
@@ -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,154 @@
|
||||
import { z } from "zod";
|
||||
import { TSurveyElementTypeEnum } from "./surveys/constants";
|
||||
|
||||
// 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.cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string().min(1),
|
||||
type: ZConnectorType,
|
||||
status: ZConnectorStatus,
|
||||
workspaceId: z.cuid2(),
|
||||
feedbackRecordDirectoryId: z.cuid2(),
|
||||
lastSyncAt: z.date().nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
});
|
||||
export type TConnector = z.infer<typeof ZConnector>;
|
||||
|
||||
// Formbricks element mapping
|
||||
export const ZConnectorFormbricksMapping = z.object({
|
||||
id: z.cuid2(),
|
||||
createdAt: z.date(),
|
||||
connectorId: z.cuid2(),
|
||||
workspaceId: z.cuid2(),
|
||||
surveyId: z.cuid2(),
|
||||
elementId: z.string(),
|
||||
hubFieldType: ZHubFieldType,
|
||||
customFieldLabel: z.string().nullable(),
|
||||
});
|
||||
export type TConnectorFormbricksMapping = z.infer<typeof ZConnectorFormbricksMapping>;
|
||||
|
||||
export const ZConnectorFieldMapping = z.object({
|
||||
id: z.cuid2(),
|
||||
createdAt: z.date(),
|
||||
connectorId: z.cuid2(),
|
||||
workspaceId: z.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),
|
||||
creatorName: z.string().nullable().optional(),
|
||||
});
|
||||
export type TConnectorWithMappings = z.infer<typeof ZConnectorWithMappings>;
|
||||
|
||||
// Create input schemas
|
||||
export const ZConnectorCreateInput = z.object({
|
||||
name: z.string().min(1),
|
||||
type: ZConnectorType,
|
||||
feedbackRecordDirectoryId: z.cuid2(),
|
||||
createdBy: z.cuid2().optional(),
|
||||
});
|
||||
export type TConnectorCreateInput = z.infer<typeof ZConnectorCreateInput>;
|
||||
|
||||
// Create Formbricks mapping input
|
||||
export const ZConnectorFormbricksMappingCreateInput = z.object({
|
||||
surveyId: z.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(),
|
||||
lastSyncAt: z.date().nullable().optional(),
|
||||
});
|
||||
export type TConnectorUpdateInput = z.infer<typeof ZConnectorUpdateInput>;
|
||||
|
||||
// Element types that cannot be mapped to Hub fields
|
||||
export const UNSUPPORTED_CONNECTOR_ELEMENT_TYPES: readonly TSurveyElementTypeEnum[] = [
|
||||
TSurveyElementTypeEnum.ContactInfo,
|
||||
TSurveyElementTypeEnum.Address,
|
||||
TSurveyElementTypeEnum.Cal,
|
||||
TSurveyElementTypeEnum.CTA,
|
||||
TSurveyElementTypeEnum.FileUpload,
|
||||
TSurveyElementTypeEnum.Consent,
|
||||
] as const;
|
||||
|
||||
// Element type to Hub field type mapping helper (only supported types)
|
||||
export const ELEMENT_TYPE_TO_HUB_FIELD_TYPE: Record<string, THubFieldType> = {
|
||||
openText: "text",
|
||||
nps: "nps",
|
||||
rating: "rating",
|
||||
multipleChoiceSingle: "categorical",
|
||||
multipleChoiceMulti: "categorical",
|
||||
date: "date",
|
||||
matrix: "categorical",
|
||||
ranking: "categorical",
|
||||
pictureSelection: "categorical",
|
||||
};
|
||||
|
||||
// Helper function to get Hub field type from element type
|
||||
export const getHubFieldTypeFromElementType = (elementType: string): THubFieldType => {
|
||||
return ELEMENT_TYPE_TO_HUB_FIELD_TYPE[elementType];
|
||||
};
|
||||
Generated
+8
@@ -141,6 +141,9 @@ importers:
|
||||
'@formbricks/email':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/email
|
||||
'@formbricks/hub':
|
||||
specifier: 0.4.3
|
||||
version: 0.4.3
|
||||
'@formbricks/i18n-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n-utils
|
||||
@@ -2174,6 +2177,9 @@ packages:
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
|
||||
|
||||
'@formbricks/hub@0.4.3':
|
||||
resolution: {integrity: sha512-PYNWgiue3BBYVzQ1Avf06wTFyRpjIYGHjNVTB/5hMxvYzuPoycnbPzJcTXZi1zTBWHAHOLPBjIl42EQy4BIFWw==}
|
||||
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
@@ -13645,6 +13651,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formbricks/hub@0.4.3': {}
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
|
||||
+3
-1
@@ -296,7 +296,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